概述
交互式程序通常需要用户手动完成一些操作,因此常常会成为系统管理自动化和测试自动化中的障碍。最早出现在 Unix 上的 Expect 语言可以用来和 passwd/ssh/telnet/ftp 等命令行程序进行交互,将用户从这些手工操作中解放出来。作为 Tcl 语言的扩展,Expect 最初由 Tcl 编写,但是现在已经有了 Perl 和 Python 的实现。Perl 作为最为流行的脚本语言之一,整合了 C/sh/sed/awk 的优点且和系统结合紧密,已成为系统管理员有力的工具。本文将介绍 Perl 中的 Expect 和 Expect::Simple 两个模块,结合系统管理和软件测试实例,说明如何实现与命令行程序的自动化交互。
局限性
Perl 的 Expect 模块依赖于 IO::Tty,而 IO::Tty 只适用于 POSIX 兼容系统,所以该模块目前还无法直接在 Windows 环境下使用。有两个变通的方法:一是使用 Cygwin 虚拟机;二是基于 ActiveState 提供的 Expect for Windows 工具,使用 Tcl 语言编写自动化脚本。本文所使用的环境为 RHEL5.3 32 位版本,Perl 5.8.8。
下载和安装
Expect 和 Expect::Simple 都可以从 CPAN 网站上直接下载,最新版本为 Expect-1.21 和 Expect::Simple-0.04。比较方便的安装方法是使用 Perl 自带的包管理工具 cpan:
清单 1. 模块安装
perl -MCPAN -e 'install Expect::Simple'
请确保有足够的执行权限,cpan 将自动解决模块间依赖关系。由于 Expect::Simple 依赖于 Expect,安装 Expect::Simple 的过程中,Expect 模块以及另外的依赖模块都会自动安装完毕。
Expect 模块详解
与最初的 Expect 语言类似,Expect 模块的主要功能也通过 spawn,expect,send 三个方法实现。
spawn:启动目标程序
清单 2. spawn 方法原型
$obj = Expect->spawn( $command, @parameters );
spawn 是 Expect 类的主要方法之一,通过 fork 和 exec 启动目标程序,若成功则返回 Expect 对象,否则返回 undef。参数 $command 指定目标程序,@parameters 为可选参数。下面是一个简单的例子:
清单 3. spawn 用法示例
$obj1 = Expect->spawn( "ftp 9.9.9.9" ); # 启动 ftp 进程
$obj2 = Expect->spawn( "ftp", "9.9.9.9" ); # 与上一行等效
上述两行执行结果相同,但其实际处理过程存在细微差别。一般情况下我们可以把完整的命令行 ( 甚至可以是复合命令,包括命令、参数、管道符、重定向符等 ) 都写入 $command 而不指定 @parameters。
注:在 spawn 的实现中,$command 和 @parameters 都原封不动地传递给了 Perl 的 exec 函数。根据 exec 函数的说明文档,如果传递进来的是多元列表参数,exec 直接将其传递给 execvp 系统调用;如果传递进来的是标量参数或者单元列表参数,exec 函数将检查是否存在 shell 元字符 ( 如 | & ; ( ) < > 等 ),若存在,则将此参数交给系统 shell 进行解析,否则将其分词后传递给 execvp 系统调用。因此如果 spawn 的是一个含有 shell 元字符的复合命令,我们一般只能将其完整写入 $command。
expect:等待特定输出
清单 4. expect 方法原型
$obj->expect( $timeout, @match_patterns );
使用 Expect 对象的 expect 方法等待目标程序的特定输出。参数列表中 $timeout 设定超时 ( 以秒为单位 ),@match_patterns 提供一个或多个匹配模式,如果在设定时间内目标程序输出结果和 @match_patterns 中某元素匹配则成功返回。缺省情况下 expect 使用精确匹配,若想使用正则表达式,可以在该模式元素前加 '-re' 前缀 :
清单 5. 启用正则表达式匹配
$obj->expect( 10, 'match me exactly', '-re'=>'match\s+me\s+exactly' );
标量上下文中 expect 返回匹配模式在 @match_patterns 中的位置 ( 注意下标从 1 开始 ),若不成功则返回 undef。而列表上下文中 expect 返回一个包含详细匹配信息的列表:
清单 6. expect 方法的列表返回值
( $pos, $err, $match, $before, $after ) = $obj->expect( $timeout, @patterns );
其中 $pos 就是在标量环境中的返回值,$err 是出错信息,$match 为成功匹配的字串,$before 为匹配字串之前的输出部分,$after 为匹配字串之后的输出部分。下面这个例子 (matchinfo.pl 及其输出结果 ) 具体说明了这些值的含义:
清单 7. matchinfo.pl
#! /usr/bin/perl
# 9.125.13.44 是一台 ftp 服务器,使用 ftp 命令登录时的输出为:
# Connected to 9.125.13.44.
# 220 AIX6144 FTP server (Version 4.2 Tue Dec 22 14:13:26 CST 2009) ready.
# 502 authentication type cannot be set to GSSAPI
# 502 authentication type cannot be set to KERBEROS_V4
# KERBEROS_V4 rejected as an authentication type
# Name (9.125.13.44:root):
use Expect;
$obj = Expect->spawn( "ftp 9.125.13.44" ) or die "Couldn't spawn ftp, $!";
# 关闭目标程序的输出显示
$obj->log_stdout( 0 );
# 使用 slice 获取列表环境下的返回值
@result{ "position", "error", "match", "before", "after" } = $obj->expect( 10, 'Name' );
# 查看匹配结果
print "$_ = $result{$_}\n" foreach ( keys %result );
# 关闭目标程序
$obj->soft_close( );
为了使运行结果更加清楚,示例中使用 $obj->log_stdout(0)关闭目标程序的输出显示。该程序的运行结果为:
清单 8. matchinfo.pl 输出结果
after = (9.125.13.44:root):
match = Name
error =
position = 1
before = Connected to 9.125.13.44.
220 AIX6144 FTP server (Version 4.2 Tue Dec 22 14:13:26 CST 2009) ready.
502 authentication type cannot be set to GSSAPI
502 authentication type cannot be set to KERBEROS_V4
KERBEROS_V4 rejected as an authentication type
Expect 对象也提供了单独的方法来获得这些匹配信息,如 $obj->before( ),$obj->after( ),$obj->match( ),$obj->error( ) 等。
使用 -i 选项可以对多个或多组 Expect 对象同时执行 expect 操作:
清单 9. -i 选项
$obj1 = Expect->spawn( "ftp 9.125.13.44" ) or die $!;
$obj2 = Expect->spawn( "telnet 9.9.9.9" ) or die $!;
$obj3 = Expect->spawn( "ssh 9.181.59.64" ) or die $!;
expect ( $timeout,
'-i', $obj1, '-re', qr/name/i,
'-i', [$obj2, $obj3], '-re', qr/login/i, '-re', qr/password/i,
)
本例使用函数风格的 expect 方法 ( 区别于通过对象调用 ),构造匿名数组的引用 [...] 来传递多个 Expect 对象。
此外在 $obj->expect($timeout, @match_patterns)中,@match_patterns 还可采用 [ $regexp, sub{}, @opt_params ]的形式,根据不同模式来执行不同后续操作。逻辑流程较为简单时,利用此特点可以使代码组织更加紧凑。下面的 telnet 登录脚本 (exptelnet.pl) 采用了这种形式:
清单 10. exptelnet.pl
#! /usr/bin/perl
use Expect;
my $PROMPT = '[\]\$\>\#]\s*$'; # 远程系统的命令提示符模式
$obj = Expect->spawn( "telnet 9.125.13.44" ) or die "Couldn't spawn telnet, $!";
$obj->expect( 10,
[ qr/login:\s*$/i,
sub{ my $self = shift; $self->send( "root\r" ); exp_continue;}
],
[ qr/password:\s*$/i,
sub{ my $self = shift; $self->send( "zu88jie\r" ); exp_continue;}
],
[ qr/$PROMPT/,
sub{my $self=shift; $self->send( "logout\r" ); exp_continue_timeout;}
],
);
示例中匿名函数返回 exp_continue 符号将重置等待时间并继续执行 expect,使得一次 expect 调用可以完成多次匹配动作。与之相对的是返回 exp_continue_timeout 符号,在继续执行 expect 时不重置等待时间。
send:发送数据
清单 11. send 方法原型
$obj->send( @strings );
当交互式程序等待用户输入时,可以使用 send 方法向其提供输入数据。需要注意,send 送出的数据可能会回显在终端上 ( 具体与终端设置有关 ),此数据会进入 Expect 对象的匹配缓冲区,被下一个 expect 动作接收。为了避免 send 数据对 expect 匹配造成混乱,一般可以使用 $obj->stty( "-echo" )方法关闭终端回显,或者在 spawn 前使用 $obj->raw_pty(1)将终端设定成 raw 模式 (raw 模式将关闭回显,禁止回车 - 换行符翻译 ),或者为 expect 提供更加精确的匹配模式。
log_file:设置日志记录
清单 12. log_file 方法原型
$obj->log_file( $filename | $filehandle | undef );
将交互过程的内容输出到日志文件能便于自动化脚本的追踪和调试。通常使用文件名或者文件句柄作为参数来指定具体日志文件,例如:
清单 13. 设置日志记录
$obj = Expect->spawn( "ftp 9.9.9.9" );
# 使用"w"选项截断旧日志
$obj->log_file( "./out.log", "w" );
debug:设置调试信息
清单 14. debug 方法原型
$obj->debug( 0 | 1 | 2 | 3 );
参数 0 为禁止调试信息 ( 缺省值 ),1 ~ 3 级详细程度递增。另一个相关的方法是 $object->exp_internal( 1 | 0 ),用以打开或关闭 expect 对象的内部调试信息。将此标志设置为 1 可以看到 expect 对象对输出内容尝试匹配的完整过程 (expdebug.pl):
清单 15. expdebug.pl
#! /usr/bin/Perl
use Expect;
$obj = Expect->spawn( "ftp 9.125.13.44" ) or die $!;
$obj->exp_internal( 1 ); # 打开对象内部调试信息
$obj->expect( 10, "Name" );
$obj->soft_close( );
运行结果为:
清单 16. expdebug.pl 输出详细匹配过程
Starting EXPECT pattern matching...
... 省略中间输出 ...
spawn id(3): Does `'
match:
pattern #1: -ex `Name'? No.
... 省略中间输出 ...
match:
pattern #1: -ex `Name'? YES!!
Before match string: `Connected to 9.125.13.44.\r\n220 AIX6144 FTP server ...'
Match string: `Name'
After match string: ` (9.125.13.44:root): '
Matchlist: ()
从结果中可以监视目标程序每一条输出与 expect 模式的匹配情况,若匹配成功还能查看 before\match\after 字串,这对于调试程序大有帮助。
interact:返回交互模式
清单 17. interact 方法原型
$obj->interact( );
为了适应某些特殊场合,我们可能需要将控制权交还给目标程序,此时只需使用 $obj->interact( )方法。
clear_accum 与 set_accum:操纵匹配缓冲区
清单 18. clear_accum 与 set_accum 方法原型
$obj->clear_accum( );
$obj->set_accum( $value );
expect 方法针对 Expect 对象的匹配缓冲区 (accumulator) 进行匹配尝试,默认情况下每次匹配成功后,accumulator 中 before 和 match 部分将被清除,下次匹配从 after 开始。但是 Expect 对象提供了 clear_accum 与 set_accum 方法改变这种行为:使用 $obj>set_accum( $value )将缓冲区内容设置成 $value,使用 $obj>clear_accum( )清空缓冲区内容。具体用法参见如下代码片段:
清单 19. 操纵匹配缓冲区内容
$obj->notransfer( 1 );
$obj->expect( $timeout,
# 1 保留 accumulator 内容 , pattern1 将被再次匹配
[ "pattern1",
sub { my $self = shift; ... }
],
# 2 将 accumulator 内容设置为 after string,即截断 before 和 match string
[ "pattern2",
sub { my $self = shift; ...; $self->set_accum( $self->after( ) );}
],
# 3 将 accumulator 内容清空
[ "pattern3",
sub { my $self = shift; ...; $self->clear_accum( );}
],
);
示例中 $obj->notransfer( 0 | 1 )方法用于设置是否保留匹配缓冲区内容。0 是默认行为 ( 清除 before 和 match);1 为保留所有内容。保留缓冲区内所有内容会导致原先匹配过的模式被再次匹配 (#1)。我们也可手动设置 accumulator 内容来影响下一次匹配 (#2 #3)。
Expect::Simple 模块详解
Expect::Simple 对 Expect 模块进行了封装,隐藏其内部复杂机制。此模块处理一些简单的应用已经足够。
new;构造方法
清单 20. new 方法原型
$obj = Expect::Simple->new( \%attr );
创建 Expect::Simple 对象的同时启动目标程序,需传递关联数组 %attr 的引用作为参数。通过该关联数组设置目标程序的相关信息 ( 如命令、超时、提示符等 ),因此该数组必须包含 Prompt,DisconnectCmd,Cmd 等键值。
Cmd 键指定目标程序及其参数。它的值可以是标量或者是数组引用:Cmd => $command或 Cmd => [ $command, $arg1, $arg2, ...]。在 Expect::Simple 的实现中 $command 或 $command, $arg1, $arg2, ...都被直接传给 Expect 的 spawn 方法,因此前面对 spawn 方法的分析在这里同样适用:可以把完整的命令行写入 $command;对不含 shell 元字符的简单命令,也可以使用分拆形式,传递数组引用。
Prompt 键指定一个或一组预期的输入提示 ( 支持正则表达式 ):Prompt => ['ftp>', 'telnet>', -re => 'prompt\d+>\s+']。Prompt 键值相当于 Expect 模块中 expect 方法的匹配模式参数。
DisconnectCmd 键指定退出目标程序所用的指令:DisconnectCmd => 'exit'。
Timeout 键设定超时 ( 缺省值为 1000 秒 ),如果目标程序在设定时间内未响应则返回。Expect::Simple 在目标程序启动之前设置超时且使用全局设定,因此无法区分处理目标程序执行过程中立即响应部分和有明显延时的部分。在设定超时值时需要考虑响应最慢的阶段。
RawPty 键用于设置终端,对应于 Expect 模块的 raw_pty 方法,默认值为 0。
Verbose 键用于设定输出内容的详细程度:Verbose => 3。
send:顺序发送数据
使用 $obj->send( $cmd | @cmds)向目标程序发送一条或依次发送多条数据。每条数据送达后等待下一个输入提示以发送下一条数据。以 ssh 自动登录为例,serialsend.pl 演示如何顺序发送多条数据:
清单 21. serialsend.pl
#! /usr/bin/perl
use Expect::Simple;
my %attr = (
Prompt => [ -re => qr/password:\s*$/i,
-re => qr/[\]\$\>\#]\s*$/ ],
Cmd => 'ssh root@9.125.13.44',
DisconnectCmd => 'exit',
Verbose => 3,
Timeout => 10,
);
my $obj = Expect::Simple->new( \%attr );
# 自动输入密码,执行 ls 命令,退出。由于已设定退出命令,此处不必再发送 exit
$obj->send( "zhu88jie", "ls" );
输出结果:
清单 21. serialsend.pl 执行结果
Running command...done.
Sending `zhu88jie'
Sending `ls'
Disconnecting.
若想看到 ls 命令的执行输出,只需将 Verbose 值设为 4。
before,after,match_str,error:查看匹配信息
这些方法和 Expect 模块中的 before,after,match,error 一一对应,不再详细阐述。需要注意由于 Expect 对象的缓冲区在匹配过程中会不断更新,在依次发送多条数据的情况下,使用这些方法只能查看最近一次匹配结果。
expect_handle:使用内部对象
Expect::Simple 封装了 Expect 模块的细节,提供简单易用的接口,但是当 Expect::Simple 提供的功能无法以满足需求时,还可以使用这个方法直接操作内部 Expect 对象。
应用示例
Expect 模块使用示例
本例 (ftpdemo.pl) 演示如何使用 Expect 模块从 ftp 上自动下载文件。
清单 22. ftpdemo.pl
#!/usr/bin/perl
# Usage: ftpdemo.pl [-u username] [-p password] host file1 [file2 file3 ...]
use Expect;
use Getopt::Std;
# 设置缺省用户名和密码
my %opts = ( u=>'anonymous', p=>'anonymous@mycompany.com' );
# 解析 -u 和 -p 选项
getopt( 'up', \%opts );
$host = shift @ARGV; # 下一个参数是 ftp 服务器地址
@files = @ARGV; # 余下的参数为需要下载的文件
# 启动 ftp 进程
print "Starting ftp session with server $host ...\n";
$ftp = Expect->spawn( "ftp $host" ) or die "Couldn't spawn ftp, $!";
# 屏蔽多余输出
$ftp->log_stdout( 0 );
# 等待用户名输入提示
unless ( $ftp->expect(30, -re=>qr/name \(.*?\):\s*$/i) ) {
die "Never got username prompt on $host, ".$ftp->error( )."\n";
}
# 发送用户名数据
print "Sending username ($opts{u}) ... \n";
$ftp->send( "$opts{u}\r" );
# 等待密码输入提示
unless ( $ftp->expect( 30, -re=>qr/password:\s*$/i ) ) {
die "Never got password prompt on $hostname, ".$ftp->error( )."\n";
}
# 发送密码
print "Sending password ( $opts{p} ) ... \n";
$ftp->send( "$opts{p}\r" );
# 等待 ftp 命令行提示
unless ( $ftp->expect(30,"ftp>") ) {
die "Never got ftp prompt after sending username, ".$ftp->error( )."\n";
}
# 下载文件
foreach my $file ( @files ) {
print "Getting the $file ... \n";
$ftp->send( "get $file\r" );
unless ( $ftp->expect( 30,"ftp> " ) ) {
die "Never got ftp prompt after attempting to get $file, ".$ftp->error( )."\n";
}
}
# 断开 ftp 连接
print "Download finished. Disconnecting ... \n";
$ftp->send( "bye\r" );
$ftp->soft_close( );
print "Done.\n";
使用此脚本从 9.125.13.44 的 ftp 根目录下载 diskusage.log 文件,运行结果:
清单 23. ftpdemo.pl 执行结果
# ./ftpdemo.pl -u root -p zhu88jie 9.125.13.44 diskusage.log
Starting ftp session with server 9.125.13.44 ...
Sending username (root) ...
Sending password (zhu88jie) ...
Getting the diskusage.log ...
Download finished. Disconnecting ...
Done.
#
Expect::Simple 模块使用示例
本例演示如何使用 Expect::Simple 对目标程序的输入输出进行测试。脚本 target.pl 作为此例中的目标程序。target.pl 循环读取用户输入,直到用户输入 quit 时退出。
清单 24. target.pl
#! /usr/bin/perl
my $p = 'tpt %d> '; # 输入提示符
printf( $p, 0 );
while( <> ){
do { print "byebye\n"; last } if /quit/;
# 如果用户输入 quit 则输出"byebye",跳出循环
print uc( $_ ); # 否则以大写的形式输出用户本次输入
printf( $p, $. ); # 更新输入提示符,$. 为读入行数计数器
}
print "quit> \n"; # 打印结束输入提示符
某次运行结果如下:
清单 25. target.pl 执行结果示例
# ./target.pl
tpt 0> 1
1
tpt 1> 2
2
tpt 2> quit
byebye
quit>
#
exptest.pl 脚本用来模拟用户输入不同数据以测试目标程序的功能:
清单 26. exptest.pl
#! /usr/bin/perl
use Test::More tests => 4; # 计划执行的测试用例总数
use Expect::Simple;
my %attr = ( Cmd => "./target.pl"
DisconnectCmd => 'quit',
Prompt => [ -re => 'tpt\s\d+> ', 'quit> '],
RawPty => 1,
Verbose =>3,
);
my $res; # 实际输出结果
my $obj = Expect::Simple->new( \%attr );
# 测试用例 1,用户输入 a,预期目标程序输出 A
$obj->send( 'a' );
chomp( $res = $obj->before );
is( $res, 'A', 'Test case 1: Input "a" -> Output "A"' );
# 测试用例 2,用户输入 b,预期目标程序输出 B
$obj->send( 'b' );
chomp( $res = $obj->before );
is( $res, 'B', 'Test case 2: Input "b" -> Output "B"' );
# 测试用例 3,用户输入 quit,预期目标程序输出 byebye
$obj->send( 'quit' );
chomp( $res = $obj->before );
is( $res, 'byebye', 'Test case 3: Input "quit" -> Output "byebye"' );
# 测试用例 4,目标程序输出 byebye 后应直接输出 quit>
is ( $obj->match_str, 'quit> ', 'Test case 4: Output "byebye" -> Output "quit>" ' );
程序引用了 Test::More 模块,Test::More 是一个编写测试脚本的框架,这里只介绍程序中涉及的部分,更多内容请查阅 CPAN 网站上相关信息。整个测试过程使用 is 函数来判断用例的执行结果:is ( $got, $expected, $comments )。该函数判断 $got 与 $expected 是否相等,相等则通过,输出“ok”,否则输出“not ok”。
在 target.pl 循环执行的过程中,如果用户未输入 quit,则每次 expect 都匹配到输入提示符“tpt\s\d+>”上,而终端回显又被关闭,因此匹配缓冲区的 before 部分即为目标程序上次输出的结果。程序中使用 $res 变量获得目标程序的实际输出,与预期结果比较判断测试用例是否通过。exptest.pl 实际执行的结果为:
清单 27.exptest.pl 执行结果
# ./exptest.pl
1..4
Running command...done.
Sending `a'
ok 1 - Test case 1: Input "a" -> Output "A"
Sending `b'
ok 2 - Test case 2: Input "b" -> Output "B"
Sending `quit'
ok 3 - Test case 3: Input "quit" -> Output "byebye"
ok 4 - Test case 4: Output "byebye" -> Output "quit>"
Disconnecting.
#