跳转至

教你编写控制台程序

前言

本文章所有示例代码均在 GitHub

建议阅读本文后可以扩展性的阅读Term::CLI模块提供的Tutorial

无论是出于何目的,我们对一个程序运维到一定程度后,都会想要为其编写一个控制台程序,以便于我们更好的管理它。本文将教你如何使用 Perl 编写一个控制台程序。

对于编写控制台第一件事就是要确定是否有可用的开源项目可以使用或者参考,这样我们往往都可以达到事半功倍的效果,并且也可以提升个人的编程能力。在 Perl 中,我们可以使用 Term::CLI 模块来编写控制台程序。

安装 Term::CLI

cpanm Term::CLI

编写控制台程序

在安装完 Term::CLI 后,我们就可以开始编写控制台程序了。首先,我们需要创建一个文件夹,用于存放我们的控制台程序,然后在该文件夹下创建一个 myapp.pl 文件,用于存放我们的控制台程序的主要逻辑。

基本框架

mkdir app && cd app && touch myapp.pl

myapp.pl 中写入以下代码,创建一个app的控制台:

  1. 控制台的提示词为app
  2. 在控制台中输入help时,会输出help
  3. 在控制台中输入exit时,会退出控制台。
#!/bin/env perl

use strict;
use warnings;

use Term::CLI;

my $cli = Term::CLI->new(
    name => 'app',
    prompt => 'app> ',
    commands => [
        Term::CLI::Command->new(
            name => 'exit',
            callback => sub { exit 0 },
        ),
        Term::CLI::Command->new(
            name => 'help',
            callback => sub { 
                my ($self, %args) = @_;

                print "help\n";

                return (%args, status => 0); 
            },
        ),
    ],
);

# 跳过空行和注释行
while ( my $input = $cli->readline(skip => qr/^\s*(?:#.*)?$/) )
{
   $cli->execute_line($input);
}
  • Term::CLI->new:创建一个控制台对象
    • name:名称;一些默认的文件名会使用该名称
    • prompt:控制台的提示符
    • command:控制台的命令
      • Term::CLI::Command->new:创建一个命令对象
        • name:命令的名称
        • callback:命令的回调函数;当用户输入命令时,会调用该回调函数
          • my ($self, %args) = @_;:获取命令的参数
          • return (%args, status => 0);:返回命令的参数,其中status为命令的状态码。如果异常应该返回-1,正常返回0。注意:这里是一种固定返回格式,下文会对此展开说明,这里先不展开。

运行上述程序,将会得到以下结果:

app>        # 首次登陆控制台,会显示提示符
app>        # 按 <Tab> 键,会显示所有的命令
exit  help
app> help   # 输入help,打印help
help
app> exit   # 输入exit,退出控制台

记录输入的历史记录

记录输入的历史记录是一个很有用的功能,它可以让我们在下次使用控制台时,可以通过上下键来选择历史记录中的命令。在 Term::CLI 中,我们可以通过cleanup$cli->read_history$cli->write_history来实现该功能。

#省略...

my $cli = Term::CLI->new(
    name        => 'app',
    prompt      => 'app> ',
    cleanup     => sub {
        my ($self) = @_; # 因为是回调函数,所以第一个参数为控制台对象

        # 将历史记录写到文件 `~/.app_history` 中
        # 如果写入失败,会将错误信息写入到 `$self->error` 中
        $self->write_history
           or warn "cannot write history: " . $self->error . "\n";
    },
    commands => [
        # 省略...
    ],
);

$cli->read_history;  # 从文件 `~/.app_history` 读取历史记录

# 跳过空行和注释行
# while ( my $input = $cli->readline(skip => qr/^\s*(?:#.*)?$/) )
# 省略...

给命令添加说明

在使用命令行的时候,我们希望能够通过help命令来查看命令的说明,这样我们就可以更好的使用命令行。在 Term::CLI 中,我们可以通过Term::CLI::Command->newdescriptionsummaryusage参数来设置说明,并通过完善help命令来实现该功能。

# 省略...

# 创建一个 `help` 命令对象;后面可以被复用到每个命令中
my $cmd_help = Term::CLI::Command->new(
    name            => 'help',
    description     => '查看帮助信息',
    summary         => '查看帮助信息',
    usage           => 'help [ command ]',
    callback        => \&cb_help, # 设置命令的回调函数为 `cb_help`
);

my $cli = Term::CLI->new(
    # 省略...
    commands => [
        Term::CLI::Command->new(
            name        => 'exit',
            description => '退出控制台',
            summary     => '退出控制台',
            usage       => 'exit',
            callback    => sub { exit 0 },
        ),
        $cmd_help, # 引用 `help` 命令对象
    ],
);

sub cb_help
{ 
    my ($self, %args) = @_;

    my $line    = $args{command_line};          # 提取命令行

    #
    # 概述:提取前一个命令对象(后续称为父命令):当我们输入`help`时,其实会默认加入一个根对象,
    #   根对象名称就是控制台的名称,即`app`,即`app help`。因此这里提取的是`app`命令对象。
    #
    # 详述:
    #   通过 `command_path` 可以获取到命令对象的路径,比如这里的路径为:`app help`,
    #   因此,`$args{command_path}->[-2]` 意思是倒数第二个命令对象,即 `app` 命令对象,
    #   同理,`$args{command_path}->[-1]` 意思是倒数第一个命令对象,即 `help` 命令对象。
    my $pcmd    = $args{command_path}->[-2];

    my ($help_cmd) = ($line =~ /help (\w+)$/); # 提取 `help` 之后的命令

    if (defined $help_cmd)
    {
        #
        # 如果要查看指定命令的使用说明,则打印该命令的详细说明和使用方法,比如:help help
        # 效果类似于:
        #   app> help help
        #   ===== 详细说明 =====
        #   查看帮助信息
        #
        #   ===== 使用方法 =====
        #   help [ command ]
        #

        my $cmd     = $pcmd->find_command($help_cmd); # 查找命令对象

        $cmd || return (%args, status => -1, error => $pcmd->error); # 如果命令不存在,则返回错误

        print "===== 详细说明 =====\n";
        print $cmd->description . "\n\n";
        print "===== 使用方法 =====\n";
        print $cmd->usage . "\n\n";
    }
    else
    {
        #
        # 如果输入了 `help` 命令,但是没有输入命令名称,则打印所有属于父命令的子命令的概述
        # 效果类似于:
        #   app> help
        #   exit            退出控制台
        #   help            查看帮助信息

        foreach my $name ($pcmd->command_names)
        {
            my $cmd = $pcmd->find_command($name);

            my $summary = $cmd->summary // "unknwon";

            printf "%-15s\%s\n", $name, $summary;
        }
    }

    return (%args, status => 0);
}

加上上述代码后,执行效果如下:

app> help
exit           退出控制台
help           查看帮助信息
app> help help
===== 详细说明 =====
查看帮助信息

===== 使用方法 =====
help [ command ]

app> 

给命令定义参数

参数分为可选参数和必选参数,可选参数可以通过-来指定,比如ls -l,而必选参数则不需要-,比如ls /path/to/dest

  • 必选参数:通过Term::CLI::Command->newarguments参数来实现该功能。
  • 可选参数:通过Term::CLI::Command->newoptions参数来实现该功能。

假设我们现在想添加一个ls [OPTIONS] <PATH>命令,该命令可以接收-l可选参数,如果输入了-l参数,则会打印出<PATH>路径下的所有文件的详细信息,否则只打印文件名。

# 省略...

my $cli = Term::CLI->new(
    # 省略...
    commands => [
        Term::CLI::Command->new(
            name        => 'exit',
            description => '退出控制台',
            summary     => '退出控制台',
            usage       => 'exit',
            callback    => sub { exit 0 },
        ),
        $cmd_help, # 引用 `help` 命令对象
        Term::CLI::Command->new(
            name        => 'ls',
            description => '查看指定路径下的文件',
            summary     => '查看指定路径下的文件',
            usage       => "ls [OPTIONS] <PATH>\n".
                           "\n".
                           "PATH: 要查看的路径\n".
                           "\n".
                           "OPTIONS:\n".
                           "  -l   显示文件详细信息",
            arguments   => [
                Term::CLI::Argument::String->new(
                    name        => 'path',
                    min_length  => 1,
                    max_length  => 128,
                ),
            ],
            options     => [ "l" ],
            callback    => \&cb_ls,
        )
    ],
);

sub cb_ls
{
    my ($self, %args) = @_;

    return (%args) if ($args{status} < 0);

    my @arguments = @{$args{arguments}}; # 提取命令行参数
    my %options   = %{$args{options}};   # 提取命令行选项

    my $path = $arguments[0];

    my @files;

    if (-d $path)
    {
        opendir(my $dh, $path) 
            || return (%args, status => -1,  error => "无法打开目录:$!");

        @files = readdir($dh);
        closedir($dh);
    }
    elsif (-e $path)
    {
        push @files, $path;
    }
    else
    {
        return (%args, status => -1, error => "无效的路径:$path")
    }

    unless(exists $options{l})
    {
        # 如果没有输入 `-l` 参数,则只打印文件名

        foreach my $file (@files)
        {
            next if ($file eq '.' || $file eq '..');
            print "$file\n";
        }
    }
    else
    {
        # 如果输入了 `-l` 参数,则打印文件详细信息

        foreach my $file (@files)
        {
            next if ($file eq '.' || $file eq '..');

            my $filepath = File::Spec->catfile($path, $file);
            my @stat = stat($filepath);

            printf "%-20s %10d %s\n", 
                $file, $stat[7], scalar localtime($stat[9]);

        }
    }

    return (%args, status => 0);
}

# 省略...

执行上述代码效果与下面相似。

没有输入-l<PATH>参数:

app> ls .
.devcontainer
.git
LICENSE
README-zh.md
README.md
myapp-zh.pl
myapp.pl

输入了-l参数,但是没有输入<PATH>参数:

app> ls -l .
.devcontainer               160 Fri Jun 30 09:53:02 2023
.git                        480 Fri Jun 30 10:03:14 2023
LICENSE                    1063 Fri Jun 30 09:53:02 2023
README-zh.md                244 Fri Jun 30 09:57:39 2023
README.md                   363 Fri Jun 30 10:00:52 2023
myapp-zh.pl                5417 Fri Jun 30 14:23:17 2023
myapp.pl                      0 Fri Jun 30 14:31:58 2023

查看<PATH>路径下的文件:

app> ls -l /
sys                           0 Fri Jun 30 09:09:30 2023
lib64                      4096 Mon Jun 12 00:00:00 2023
lib                        4096 Tue Jun 13 03:31:02 2023
mnt                        4096 Mon Jun 12 00:00:00 2023
etc                        4096 Fri Jun 30 14:54:53 2023
dev                         340 Fri Jun 30 09:09:30 2023
proc                          0 Fri Jun 30 09:09:30 2023
var                        4096 Tue Jun 27 13:54:05 2023
home                       4096 Tue Jun 27 13:53:25 2023
opt                        4096 Mon Jun 12 00:00:00 2023
media                      4096 Mon Jun 12 00:00:00 2023
boot                       4096 Sun Apr  2 11:55:00 2023
sbin                       4096 Tue Jun 27 13:52:39 2023
usr                        4096 Mon Jun 12 00:00:00 2023
srv                        4096 Mon Jun 12 00:00:00 2023
bin                        4096 Tue Jun 27 13:53:58 2023
root                       4096 Tue Jun 27 13:54:01 2023
run                        4096 Mon Jun 12 00:00:00 2023
tmp                        4096 Fri Jun 30 15:04:00 2023
vscode                     4096 Fri Apr 29 09:53:23 2022
workspaces                 4096 Tue Jun 27 13:54:05 2023
.dockerenv                    0 Tue Jun 27 13:54:04 2023

自定义补全内容

补全是一个很有用的功能,可以让用户在输入命令时,通过Tab键来自动补全命令名称、参数、选项等。

比如在前文的ls命令的<PATH>参数,在我们没有开发补全功能前,它只是一个单纯的字符串参数,因此无法补全路径。这样在使用上将会特别难受。

我们期望用户输入了ls /bi,然后按下Tab键,则会自动补全为ls /bin/,如果按下两次Tab键,则会列出/bin/目录下的所有文件。

为了实现上述需求,我们设计了3个步骤:

  1. 实现一个Term::CLI::Command::Argument::String的子类,叫做MyCLI::Argument用于支持自定义补全功能;
  2. <PATH>参数的类型从Term::CLI::Argument::String改为`MyCLI::Argument;
  3. 定义补全方法complete_path,用于补全路径。

“MyCLI::Argument.pm”实现如下:

package MyCLI::Argument;

use strict;
use warnings;

use Term::CLI::Argument::String;

# 为了方便,我们继承了 Term::CLI::Argument::String
our @ISA = qw(Term::CLI::Argument::String);

# 重写 `new` 方法
sub new
{
    my ($class, %args) = @_;

    my $self = $class->SUPER::new(%args);

    $self->{complete} = $args{complete} if exists $args{complete};

    bless $self, $class;

    return $self;
}

# 重写 `complete` 方法
sub complete
{
    my ($self, $text, $state) = @_;

    # 如果用户没有指定 `complete` 方法,我们就调用父类的 `complete` 方法
    return $self->SUPER::complete($text, $state) unless $self->{complete};

    # 如果用户指定了 `complete` 方法,我们就调用它
    return $self->{complete}->($self, $text, $state);
}

1;

myapp.pl中改动如下:

# 省略...

# 添加Perl搜索路径:当前工程所在目录
use FindBin qw($Bin);
use lib "$Bin";

use Term::CLI 0.058002;

# 导入自定义的命令行参数类
use MyCLI::Argument;

use File::Glob qw(bsd_glob);

# 省略...

my $cli = Term::CLI->new(
    # 省略...
    commands => [
        Term::CLI::Command->new(
            name        => 'ls',
            description => '查看指定路径下的文件',
            summary     => '查看指定路径下的文件',
            usage       => "ls [OPTIONS] <PATH>\n".
                           "\n".
                           "PATH: 要查看的路径;默认为当前路径,即`.`\n".
                           "\n".
                           "OPTIONS:\n".
                           "  -l   显示文件详细信息",
            arguments   => [
                # 参数类型从 `Term::CLI::Argument::String` 改为 `MyCLI::Argument`
                MyCLI::Argument->new(
                    name        => 'path',
                    min_length  => 1,
                    max_length  => 128,
                    complete    => \&complete_path, # 设置补全方法
                ),
            ],
            options     => [ "l" ],
            callback    => \&cb_ls,
        )
    ],
);

# 省略...

# 函数实现引用了:<https://metacpan.org/dist/Term-CLI/source/lib/Term/CLI/Argument/Filename.pm>
sub complete_path
{
    my ($self, $text, $state) = @_;

    my @list = bsd_glob("$text*");

    return if @list == 0;

    if (@list == 1)
    {
        if (-d $list[0])
        {
            # Dumb trick to get readline to expand a directory
            # with a trailing "/", but *not* add a space.
            # Simulates the way GNU readline does it.
            return ("$list[0]/", "$list[0]//");
        }

        return @list;
    }

    # Add filetype suffixes if there is more than one possible completion.
    foreach (@list) {
        lstat;
        if ( -l _ )  { $_ .= q{@}; next } # symbolic link
        if ( -d _ )  { $_ .= q{/}; next } # directory
        if ( -c _ )  { $_ .= q{%}; next } # character special
        if ( -b _ )  { $_ .= q{#}; next } # block special
        if ( -S _ )  { $_ .= q{=}; next } # socket
        if ( -p _ )  { $_ .= q{=}; next } # fifo
        if ( -x _ )  { $_ .= q{*}; next } # executable
    }
    return @list;
}

在上述代码中,为了方便程序执行,我们将程序所在的目录也添加进了搜索路径,这样就可以直接执行myapp.pl了:

use FindBin qw($Bin);
use lib "$Bin"

进入控制台后,效果如下:

perl -I . 04-自定义补全内容.pl 
app> ls -l /bin/
/bin/bash*           /bin/date*           /bin/kill*           /bin/readlink*       /bin/uncompress*
/bin/bunzip2*        /bin/dd*             /bin/ln*             /bin/rm*             /bin/vdir*
/bin/bzcat*          /bin/df*             /bin/login*          /bin/rmdir*          /bin/wdctl*
/bin/bzcmp@          /bin/dir*            /bin/ls*             /bin/rnano@          /bin/ypdomainname@
/bin/bzdiff*         /bin/dmesg*          /bin/lsblk*          /bin/run-parts*      /bin/zcat*
/bin/bzegrep@        /bin/dnsdomainname@  /bin/mkdir*          /bin/rzsh@           /bin/zcmp*
/bin/bzexe*          /bin/domainname@     /bin/mknod*          /bin/sed*            /bin/zdiff*
/bin/bzfgrep@        /bin/echo*           /bin/mktemp*         /bin/sh@             /bin/zegrep*
/bin/bzgrep*         /bin/egrep*          /bin/more*           /bin/sleep*          /bin/zfgrep*
/bin/bzip2*          /bin/false*          /bin/mount*          /bin/ss*             /bin/zforce*
/bin/bzip2recover*   /bin/fgrep*          /bin/mountpoint*     /bin/stty*           /bin/zgrep*
/bin/bzless@         /bin/findmnt*        /bin/mv*             /bin/su*             /bin/zless*
/bin/bzmore*         /bin/fuser*          /bin/nano*           /bin/sync*           /bin/zmore*
/bin/cat*            /bin/grep*           /bin/netstat*        /bin/tar*            /bin/znew*
/bin/chgrp*          /bin/gunzip*         /bin/nisdomainname@  /bin/tempfile*       /bin/zsh*
/bin/chmod*          /bin/gzexe*          /bin/pidof@          /bin/touch*          /bin/zsh5*
/bin/chown*          /bin/gzip*           /bin/ps*             /bin/true*           
/bin/cp*             /bin/hostname*       /bin/pwd*            /bin/umount*         
/bin/dash*           /bin/ip*             /bin/rbash@          /bin/uname*  

给控制台工具本身添加命令行参数

作为一个合格的控制台工具,我们还需要给它本身添加命令行参数,比如--help--version等。接着我们就增加这些命令行参数。

# 省略...

use Term::CLI 0.058002;

# 导入自定义的命令行参数类
use MyCLI::Argument;

use File::Glob qw(bsd_glob);

use Getopt::Long;

my $conf_help;
my $conf_version;

GetOptions(
    'help|h'        => \$conf_help,
    'version|v'     => \$conf_version,
);

if ($conf_help)
{
    print <<EOF;
Usage: $0 [OPTIONS]

OPTIONS:
  -h, --help      显示帮助信息
  -v, --version   显示版本信息
EOF
    exit 0;
}

if ($conf_version)
{
    print "v1.0.0\n";
    exit 0;
}

# 省略...

上述代码中使用Getopt::Long去解析命令行参数,然后根据参数的值来执行不同的逻辑。

运行效果如下所示:

vscode  /workspaces/编写控制台程序 (main ) $ perl 05-给控制台工具本身添加命令行参数.pl -h
Usage: 05-给控制台工具本身添加命令行参数.pl [OPTIONS]

OPTIONS:
  -h, --help      显示帮助信息
  -v, --version   显示版本信息
vscode  /workspaces/编写控制台程序 (main ) $ perl 05-给控制台工具本身添加命令行参数.pl -v
v1.0.0

添加子命令

为了演示子命令,我们新增一个新的命令show,它拥有两个子命令分别为memorydisk,分别用于显示内存和磁盘的信息。

要实现子命令只需要在Term::CLI::Command->newcommands参数中引用子命令对应的Term::CLI::Command对象即可。

# 省略...

my $cli = Term::CLI->new(
    # 省略...

    commands => [
        # 省略...

        # 新增命令`show`,它拥有两个子命令分别为`memory`和`disk`,分别用于显示内存和磁盘的信息。
        Term::CLI::Command->new(
            name        => 'show',
            description => '显示系统信息',
            summary     => '显示系统信息',
            usage       => "show [OPTIONS] <SUBCOMMAND>\n".
                           "\n".
                           "SUBCOMMAND:\n".
                           "  memory   显示内存信息\n".
                           "  cpu      显示CPU信息",
            commands => [
                Term::CLI::Command->new(
                    name        => 'memory',
                    description => '显示内存信息',
                    summary     => '显示内存信息',
                    usage       => 'show memory',
                    callback    => \&cb_show_memory,
                ),
                Term::CLI::Command->new(
                    name        => 'disk',
                    description => '显示磁盘信息',
                    summary     => '显示磁盘信息',
                    usage       => 'show disk',
                    callback    => \&cb_show_disk,
                ),
            ]
        )
    ],
);

# 省略...

sub cb_show_memory
{
    my ($self, %args) = @_;

    return (%args) if ($args{status} < 0);

    my $memory = `free -h`;

    print $memory;

    return (%args, status => 0);
}

sub cb_show_disk
{
    my ($self, %args) = @_;

    return (%args) if ($args{status} < 0);

    my $disk = `df -h`;

    print $disk;

    return (%args, status => 0);
}

执行上述程序后,效果如下:

vscode  /workspaces/编写控制台程序 (main ) $ perl 06-添加子命令.pl 
app> help
exit           退出控制台
help           查看帮助信息
ls             查看指定路径下的文件
show           显示系统信息
app> show 
disk    memory  
app> show memory 
               total        used        free      shared  buff/cache   available
Mem:           7.8Gi       1.4Gi       606Mi       305Mi       5.8Gi       5.8Gi
Swap:          1.0Gi        33Mi       990Mi
app> show disk 
Filesystem      Size  Used Avail Use% Mounted on
overlay          95G   81G  8.6G  91% /
tmpfs            64M     0   64M   0% /dev
shm              64M     0   64M   0% /dev/shm
/dev/vda1        95G   81G  8.6G  91% /vscode
grpcfuse        234G  220G   14G  95% /workspaces/编写控制台程序
tmpfs           3.9G     0  3.9G   0% /proc/acpi
tmpfs           3.9G     0  3.9G   0% /sys/firmware
app> 

有时候我们还会有一种特殊的需求,比如:我们希望可以进入到show节点里面,然后单独的执行子命令 memory 等,而不是敲完整的命令 show memory。这在命令比较长的时候会比较方便。

为此我们需要介绍Term::CLI::Command->newrequire_sub_command参数,它的值为1时表示需要子命令,为0时表示不需要子命令,默认是为1

因此此时在上述程序中执行show会报错,如下所示:

app> show 
ERROR: missing sub-command

此时我们来修改一下show命令的Term::CLI::Command->new,将require_sub_command的值改为0即可,并且实现一个cb_show的回调函数,用于处理show命令的逻辑,它将会进入到show节点里面,然后在该节点中可以单独的执行子命令 memory 等,而不是敲完整的命令 show memory

# 省略...
        Term::CLI::Command->new(
            name        => 'show',
            description => '显示系统信息',
            summary     => '显示系统信息',
            usage       => "show [OPTIONS] <SUBCOMMAND>\n".
                           "\n".
                           "SUBCOMMAND:\n".
                           "  memory   显示内存信息\n".
                           "  cpu      显示CPU信息",
            callback    => \&cb_show,

            require_sub_command => 0, # 设置为0,表示不强制要求子命令

            commands => [
                Term::CLI::Command->new(
                    name        => 'memory',
                    description => '显示内存信息',
                    summary     => '显示内存信息',
                    usage       => 'show memory',
                    callback    => \&cb_show_memory,
                ),
                Term::CLI::Command->new(
                    name        => 'disk',
                    description => '显示磁盘信息',
                    summary     => '显示磁盘信息',
                    usage       => 'show disk',
                    callback    => \&cb_show_disk,
                ),
            ]
        )
# 省略...

sub cb_show
{
    my ($self, %args) = @_;

    # 忽略非直接执行`show`命令的情况
    unless ($args{command_line} =~ /^show\s*$/)
    {
        return %args;
    }

    # 进入 show 节点
    while (my $input = $self->readline(prompt => "show> ", skip => qr/^\s*(?:#.*)?$/))
    {
        my (%args) = $self->execute_line($input);

        if ($args{status} != 0)
        {
            print "ERROR: " . $args{error} . "\n";
        }
    }

    return %args;
}

详解Term::CLI::Command

可参阅手册:Term::CLI::Command

new方法创建一个命令行对象,然后作为Term::CLIcommands参数传入即可。Term::CLI::Comman本身也可引用Term::CLI::Command,这样就可以实现子命令的功能。

new方法中有以下参数:

  • name:命令行名称
  • description:命令行描述
  • summary:命令行摘要
  • usage:命令行使用说明
  • arguments:命令行参数
  • options:命令行可选参数
  • callback: 命令行回调函数
  • require_sub_command: 是否需要子命令;有时候我们有一些命令虽然有子命令,但是并不要求用户在输入的时候要携带子命令,比如git命令,用户可以输入git,也可以输入git commit,这里的commit就是子命令,但是git命令并不要求用户输入子命令,因此这里的require_sub_command就是用来控制这种情况的,当require_sub_command0时,表示子命令不是必须的。
  • commands: 子命令

那么命令树是如何调用的呢?理解命令树的工作原理对于我们编写命令行程序是非常有帮助的。

假设我们执行一条命令 show disk --verbose /etc,其命令树的解析流程如下:

(cli-root)
    |
    +--> Command 'show'
            |
            +--> Command 'disk'
                    |
                    +--> Option '--verbose'
                    |
                    +--> Argument '/etc'

这时它将按以下顺序调用:

  1. 调用diskcallback
  2. 调用showcallback
  3. 调用Term::CLI对象

且前者的返回值会传递给后者,后者要以此作判断。也如上示例所示,一个命令callback的入参%args实际上是前一个命令callback的返回值。而对于首个调用的Term::CLI对象,它的%args是由Term::CLI对象的execute_line方法传递过来的,它的%args中的command_line就是用户输入的命令行,command_path就是命令树的路径,arguments就是命令行中的参数,options就是命令行中的可选参数,status就是命令行的状态,error就是命令行的错误信息。

详解入参

callback调用方式如下所示:

OBJ->callback->(OBJ,
     status       => Int,
     error        => Str,
     options      => HashRef,
     arguments    => ArrayRef[Value],
     command_line => Str,
     command_path => ArrayRef[InstanceOf['Term::CLI::Command']],
);

OBJTerm::CLI::Command对象

status:表示到目前为止解析/执行的状态。它有以下含义:

  • < 0 负的状态值表示解析错误。这是一个信号,表明不应该采取任何行动,但需要进行一些错误处理。实际的解析错误可以在error键下找到。在这种情况下,链中的某个回调函数(例如Term::CLI对象上的回调函数)通常会将错误打印到STDERR。

  • 0 命令行解析为有效,并且到目前为止执行成功。

  • > 0 在执行动作过程中出现了一些错误。回调函数需要自行设置此状态。

error:在状态为负的情况下,这里将包含解析错误。在所有其他情况下,它可能包含有用的信息,也可能不包含。

options:引用一个包含所有命令行选项的哈希。与Getopt::Long设置的选项哈希兼容。

arguments:引用一个包含命令所有参数的数组,每个值都是标量值。

unprocessed:引用一个数组,该数组包含命令行中尚未解析为参数或子命令的所有单词。在解析错误的情况下,这通常包含元素,否则应为空。

command_line:传递给Term::CLI::execute方法的完整命令行。

command_path:引用一个包含“解析树”的数组,即表示到达此点的命令和子命令的对象引用列表:

[
    InstanceOf['Term::CLI'],
    InstanceOf['Term::CLI::Command'],
    ...
]
  • 列表中的第一个项目始终是顶级的Term::CLI对象。

processed:更详细的“解析树”:表示到达此点的命令行上所有元素的哈希列表,不包括Term::CLI对象本身。

[
    {
        element => InstanceOf['Term::CLI::Command'],
        value => String
    },
    {
        element => InstanceOf['Term::CLI::Argument'],
        value => String
    },
    {
        element => InstanceOf['Term::CLI::Argument'],
        value => String
    },
    ...
]

注意

command_pathargumentsoptions应被视为只读。

即使在错误的情况下,也会调用回调,因此在进行任何操作之前,您应 始终检查状态

详解返回值

从调用树的原理(前文)中我们了解到,前者调用的结果会作为后者的入参,这样我们才能根据前者的命令执行结果来判断后者是否需要执行,或者怎么做。

通常每个命令执行完后都应当把%args返回回去,以传递给后者,而命令需要关注的是%args中的statuserror

习惯性的写法是:

return (%args, status => $status, error => $error);

这样可以保证%args中的其他值不会被覆盖,只设置了我们需要的statuserror

评论