《高阶Perl》——第2章 分配表 2.1 配置文件处理

    xiaoxiao2021-07-11  252

    第2章

    分 配 表

    第1章介绍了如何用别的函数参数化函数的行为使函数更加灵活。例如,并没有把每次移动盘子就输出一条消息硬编码到hanoi()函数里,而是让其调用一个从外部传入的辅助函数。通过提供一个合适的辅助函数,可以使hanoi()输出一系列说明,或检查它自己的行动,或生成一个图形显示,而不必重新编写基本的算法。类似地,可以从total_size()函数的计算文件大小的行为中提取出目录遍历行为,得到一个更有价值和普遍适用的dir_walk()函数,它可以做许多不同的事情。

    为了从hanoi()与dir_walk()提取出行为,使用了代码引用。把别的函数作为参数传递给hanoi()与dir_walk()函数,有效地把辅助函数当成数据块。代码引用使这些成为可能。

    现在先不讲递归,而叙述代码引用的另一种用法。

    2.1 配置文件处理

    假设我们有一个应用要读取一个如下格式的配置文件:

    VERBOSITY 8 CHDIR /usr/local/app LOGFILE log ... ...

    要读取这个配置文件并根据每个指示采取适当的行动。例如,对于VERBOSITY指示,只是设置一个全局变量。而对于LOGFILE指示,则要立即重定向程序的诊断消息到指定的文件。对于CHDIR,也许可以让程序chdir指定的目录以使随后的文件操作与新的目录相关联。这意味着,在之前的例子里LOGFILE是/usr/local/app/log,而不是用户在程序运行时恰好所在的目录下的log文件。

    许多程序员会遇到这个问题并会立即想象到一个含有巨大if-else分支的函数,如下:

    sub read_config { my ($filename) = @_; open my($CF), $filename or return; # Failure while (<$CF>) { chomp; my ($directive, $rest) = split /\s+/, $_, 2; if ($directive eq 'CHDIR') { chdir($rest) or die "Couldn't chdir to '$rest': $!; aborting"; } elsif ($directive eq 'LOGFILE') { open STDERR, ">>", $rest or die "Couldn't open log file '$rest': $!; aborting"; } elsif ($directive eq 'VERBOSITY') { $VERBOSITY = $rest; } elsif ($directive eq ...) { ... } ... } else { die "Unrecognized directive $directive on line $. of $filename; aborting"; } } return 1; # Success }

    这个函数分为两部分。第一部分打开文件并每次从中读取一行。它把每行分成$directive部分(第一个单词)和$rest部分(剩余的部分)。$rest部分包含了指示的参数,如提供给LOGFILE指示的要打开的日志文件名。函数的第二部分是一棵大的if-else树,它检查$directive变量,查看它是哪个指示,如果指示不可识别,则中断程序。

    这类函数可以变得非常庞大,因为在if-else树中有许多选项。每次有人想增加一个指示,他就要改变函数增加一个elsif分句。if-else树的分枝的内容相互之间没有很多事情要做,除了它们都是可配置的琐碎事实。这样的函数违背了编程的一条重要法则:相关的东西应该放在一起;不相关的东西应该分开。

    依照此法则为这个函数提出了一个不同的结构:读取和解析文件的部分应该与配置的指示被识别后的执行动作分开。此外,实现各种不相关的指示的代码不应该一起挤进单个函数。

    2.1.1 表驱动配置

    可以把打开、读取和解析配置文件的代码与实现不同指示的不相关的代码分开。像这样把程序分成两半将可以更加灵活地修改每部分,也把代码与指示分开了。

    有read_config()的一个替代版本:

    ### Code Library: rdconfig-tabular sub read_config { my ($filename, $actions) = @_; open my($CF), $filename or return; # Failure while (<$CF>) { chomp; my ($directive, $rest) = split /\s+/, $_, 2; if (exists $actions->{$directive}) { $actions->{$directive}->($rest); } else { die "Unrecognized directive $directive on line $. of $filename; aborting"; } } return 1; # Success }

    和之前完全一样地打开、读取和解析配置文件。但不再依赖巨大的if-else分支了。而这版read_config接受一个额外的参数,$actions,它是一个行动表,read_config()每读取一个配置的指示,它将执行这些行动之一。这个表就称为分配表(dispatch table),因为它包含了read_config()读文件时将要把控制分配到的函数。变量$rest的意义和之前相同,但现在作为一个参数传递给合适的行为函数。

    一个典型的分配表如下:

    $dispatch_table = { CHDIR => \&change_dir, LOGFILE => \&open_log_file, VERBOSITY => \&set_verbosity, ... => ..., };

    分配表是一个散列,它的键(通常称为标签(tag))是指示的名称,它的值是行为(action),指向当识别出合适的指示名时调用的子例程。行为函数期望接受变量$rest作为一个参数,典型的行为如下:

    sub change_dir { my ($dir) = @_; chdir($dir) or die "Couldn't chdir to '$dir': $!; aborting"; } sub open_log_file { open STDERR, ">>", $_[0] or die "Couldn't open log file '$_[0]': $!; aborting"; } sub set_verbosity { $VERBOSITY = shift }

    如果行为很小,就可以直接把它们放到分配表里:

    $dispatch_table = { CHDIR => sub { my ($dir) = @_; chdir($dir) or die "Couldn't chdir to '$dir': $!; aborting"; }, LOGFILE => sub { open STDERR, ">>", $_[0] or die "Couldn't open log file '$_[0]': $!; aborting"; }, VERBOSITY => sub { $VERBOSITY = shift }, ... => ..., };

    通过转变为一个分配表,消除了巨大的if-else树,但是到头来还是得到了一个只小了一点的表。这看起来不太成功。但是表带来了几个好处。

    2.1.2 分配表的优势

    分配表是数据,而不是代码,所以它可以在运行时改变。你可以在你想的任何时候插入新的指示到表里。假设表含有:

    'DEFINE' => \&define_config_directive,

    其中,define_config_directive()是:

    ### Code Library: def-conf-dir sub define_config_directive { my $rest = shift; $rest =~ s/^\s+//; my ($new_directive, $def_txt) = split /\s+/, $rest, 2; if (exists $CONFIG_DIRECTIVE_TABLE{$new_directive}) { warn "$new_directive already defined; skipping.\n"; return; } my $def = eval "sub { $def_txt }"; if (not defined $def) { warn "Could not compile definition for '$new_directive': $@; skipping.\n"; return; } $CONFIG_DIRECTIVE_TABLE{$new_directive} = $def; }

    配置器现在接受这样的指示:

    DEFINE HOME chdir('/usr/local/app');

    define_config_directive()把HOME放入$new_directive并把chdir('/usr/local/app');放入$def_txt。它用eval把定义文本编译成一个子例程,然后把新的子例程装入一个主配置表,%CONFIG_DIRECTIVE_TABLE,以HOME为键。如果事实上%CONFIG_DIRECTIVE_TABLE是一开始就传递给read_config()的分配表,那么read_config()将会看到新的定义,如果在输入文件的下一行看到指示HOME,就将把一个行为关联到HOME。现在一个配置文件如下:

    DEFINE HOME chdir('/usr/local/app'); CHDIR /some/directory ... HOME

    在...里的指示是在目录/some/directory里被执行。当处理器到达HOME时,它就返回到它的家目录。也可以定义一个相同的但更健壮的版本:

    DEFINE PUSHDIR use Cwd; push @dirs, cwd(); chdir($_[0]) DEFINE POPDIR chdir(pop @dirs)

    PUSHDIR dir用标准Cwd模块提供的cwd()函数指出当前目录的名称。它把当前目录的名称保存在变量@dirs里,然后改变到目录dir。POPDIR撤销最后一个PUSHDIR的影响:

    PUSHDIR /tmp A PUSHDIR /usr/local/app B POPDIR C POPDIR

    程序改变到/tmp,执行指示A。然后改变到/usr/local/app并执行指示B。随后的POPDIR使程序回到/tmp,在那里执行指示C,最后第二个POPDIR使程序回到它开始的地方。

    为了使DEFINE能改变配置表,将不得不把它存入一个全局变量。如果明确地把表传递给define_config_directive也许更好。为此需要对read_config做一点小小的改变:

    ### Code Library: rdconfig-tablearg sub read_config { my ($filename, $actions) = @_; open my($CF), $filename or return; # Failure while (<$CF>) { chomp; my ($directive, $rest) = split /\s+/, $_, 2; if (exists $actions->{$directive}) { $actions->{$directive}->($rest, $actions); } else { die "Unrecognized directive $directive on line $. of $filename; aborting"; } } return 1; # Success }

    现在define_config_directive如下:

    ### Code Library: def-cdir-tablearg sub define_config_directive { my ($rest, $dispatch_table) = @_; $rest =~ s/^\s+//; my ($new_directive, $def_txt) = split /\s+/, $rest, 2; if (exists $dispatch_table->{$new_directive}) { warn "$new_directive already defined; skipping.\n"; return; } my $def = eval "sub { $def_txt }"; if (not defined $def) { warn "Could not compile definition for '$new_directive': $@; skipping.\n"; return; } $dispatch_table->{$new_directive} = $def; }

    有了这个改变,就可以增加一个确实有用的配置指示了:

    DEFINE INCLUDE read_config(@_);

    它安装一个新的条目到分配表里,如下:

    INCLUDE => sub { read_config(@_) }

    现在,当在配置文件里写:

    INCLUDE extra.conf

    主函数read_config()将执行行为,传递给它两个参数。第一个参数是从配置文件里得到的$rest,在这个例子里是文件名extra.conf。第二个参数还是分配表。将把这两个参数直接传递给read_config的递归调用。read_config将读取extra.conf,当它结束时就会把控制交给read_config的主调用,后者将继续处理主要的配置文件,从刚才离开的地方继续。

    为了递归调用能正确工作,read_config()必须是可重入的。破坏可重入性最简单的方法是使用全局变量,如使用一个全局文件句柄代替词法文件句柄。如果使用了一个全局文件句柄,递归调用read_config()将会用同样被主调用使用的句柄打开extra.conf,这将会关闭主配置文件。当递归调用返回时,read_config()将无法读取主文件的剩余部分,因为它的文件句柄已经关闭了。

    INCLUDE这个定义非常简单也非常实用。但它也是巧妙的,也许写read_config的时候都没有意识到。“read_config不需要是可重入的”说起来简单。然而,如果已经写了不可重入的read_config,那么有用的INCLUDE定义将不会起作用。在这里可以学到一个重要的经验:默认使函数是可重入的,因为有时递归调用带来的好处将是一个惊喜。

    可重入的函数展现了比不可重入的函数更简单和更可预见的行为。它们更加灵活因为它们可以递归地调用。INCLUDE例子表明无法总预见到所有的想递归地执行一个函数的理由。更好也更安全的是尽可能使所有函数是可重入的。

    分配表与在read_config()里硬编码相比较,另一个优势是可以使用同一个read_config函数处理两个不相关并且有完全不同指示的文件,只要每次传递一个不同的分配表给read_config()。可以通过传递一个简装的分配表给read_config()而使程序处于“初学者模式”。或者可以重复利用read_config()处理另一个带有相同基本语法的文件,只要传递给它一个带有一套不同的指示的表即可。在2.1.4节有这样的一个例子。

    2.1.3 分配表策略

    在PUSHDIR与POPDIR实现中,行为函数使用了一个全局变量,@dirs, 维护压入的目录的栈。这效果不好。可以通过让read_config()支持一个用户形参(user parameter)克服它,使系统更灵活。这是一个参数,由read_config()的主调者提供,一字不变地传递给行为:

    ### Code Library: rdconfig-uparam sub read_config { my ($filename, $actions, $user_param) = @_; open my($CF), $filename or return; # Failure while (<$CF>) { my ($directive, $rest) = split /\s+/, $_, 2; if (exists $actions->{$directive}) { $actions->{$directive}->($rest, $user_param, $actions); } else { die "Unrecognized directive $directive on line $. of $filename; aborting"; } } return 1; # Success }

    这消除了全局变量,因为现在可以像这样定义PUSHDIR和POPDIR了:

    DEFINE PUSHDIR use Cwd; push @{$_[1]}, cwd(); chdir($_[0]) DEFINE POPDIR chdir(pop @{$_[1]})

    形参$_[1]指向被传递给read_config()的用户形参参数。如果read_config()这样调用:

    read_config($filename, $dispatch_table, \@dirs);

    那么PUSHDIR和POPDIR将用数组@dirs作为它们的栈,如果它这样调用:

    read_config($filename, $dispatch_table, []);

    那么它们将使用一个崭新的、匿名的数组作为栈。

    向一个行为回调传递一个要执行的行为的标签名称常常是有用的。为此,可以改变read_config():

    ### Code Library: rdconfig-tagarg sub read_config { my ($filename, $actions, $user_param) = @_; open my($CF), $filename or return; # Failure while (<$CF>) { my ($directive, $rest) = split /\s+/, $_, 2; if (exists $actions->{$directive}) { $actions->{$directive}->($directive, $rest, $actions, $user_param); } else { die "Unrecognized directive $directive on line $. of $filename; aborting"; } } return 1; # Success }

    为什么这是有用的?参考为VERBOSITY指示定义的行为:

    VERBOSITY => sub { $VERBOSITY = shift },

    容易想象会有一些配置指示遵循这个通用模式:

    VERBOSITY => sub { $VERBOSITY = shift }, TABLESIZE => sub { $TABLESIZE = shift }, PERLPATH => sub { $PERLPATH = shift }, ... etc ...

    把这三个类似的行为合并成单个做这三件工作的函数。为此,函数需要知道指示的名称以便设置合适的全局变量:

    VERBOSITY => \&set_var, TABLESIZE => \&set_var, PERLPATH => \&set_var, ... etc ... sub set_var { my ($var, $val) = @_; $$var = $val; }

    或者,如果你不喜欢一堆松散的全局变量,你可以把配置信息保存到一个散列里,然后传递这个散列的引用作为用户形参:

    sub set_var { my ($var, $val, undef, $config_hash) = @_; $config_hash->{$var} = $val; }

    在这个例子里,节省的不多,因为行为如此简单。然而可能有几个配置指示需要共享一个更复杂的函数。这里有一个稍微复杂些的例子:

    sub open_input_file { my ($handle, $filename) = @_; unless (open $handle, $filename) { warn "Couldn't open $handle file '$filename': $!; ignoring.\n"; } }

    这个open_input_file()函数可以被许多配置指示分享。例如,假设一个程序有三个输入文件:一个历史文件、一个临时文件和一个模式文件。希望这三个文件的位置都可以在配置文件里配置,这需要在分配表里有三个条目。但是三个条目都可以共享相同的open_input_file()函数:

    ... HISTORY => \&open_input_file, TEMPLATE => \&open_input_file, PATTERN => \&open_input_file, ...

    现在假设配置文件认为:

    HISTORY /usr/local/app/history TEMPLATE /usr/local/app/templates/main.tmpl PATTERN /home/bill/app/patterns/default.pat

    read_config()将看到第一行并分配给open_input_file()函数,传递给它的参数列表是('HISTORY','/usr/local/app/history')。open_input_file()将参数HISTORY

    作为文件句柄名,并把HISTORY文件句柄打开到文件/usr/local/app/history。第二行,

    read_config()将再次分配给open_input_file(),这次传递给它('TEMPLATE','/usr/local/app/templates/main.tmpl')。这次,open_input_file()将打开TEMPLATE

    句柄而不是HISTORY句柄。

    2.1.4 默认行为

    例子中的read_config()函数一遇到无法识别的指示就会崩溃。这种行为是硬编码在其中的。如果分配表自身携带了对一个无法识别的指示要做什么的信息,那会更好。增加这个功能很简单:

    ### Code Library: rdconfig-default sub read_config { my ($filename, $actions, $userparam) = @_; open my($CF), $filename or return; # Failure while (<$CF>) { chomp; my ($directive, $rest) = split /\s+/, $_, 2; my $action = $actions->{$directive} || $actions->{_DEFAULT_}; if ($action) { $action->($directive, $rest, $actions, $userparam); } else { die "Unrecognized directive $directive on line $. of $filename; aborting"; } } return 1; # Success }

    这里的函数在行为表里寻找指定的指示,如果没有,它就寻找_DEFAULT_行为,仅当分配表里没有指定的默认行为时崩溃。这里有一个典型的_DEFAULT_行为:

    sub no_such_directive { my ($directive) = @_; warn "Unrecognized directive $directive at line $.; ignoring.\n"; }

    由于把指示的名称作为第一个参数传递给行为函数,因此默认的行为知道调用无法识别的指示代表什么。由于no_such_directive()函数也得到了传递的整个分配表,因此它可以抽取到真实的指示名称并通过模式匹配指出可能的含义。这里no_such_directive()用一个假想的score_match()函数判断哪个表条目良好地匹配无法识别的指示:

    sub no_such_directive { my ($bad, $rest, $table) = @_; my ($best_match, $best_score); for my $good (keys %$table) { my $score = score_match($bad, $good); if ($score > $best_score) { $best_score = $score; $best_match = $good; } } warn "Unrecognized directive $bad at line $.;\n"; warn "\t(perhaps you meant $best_match?)\n"; }

    现在拥有的系统只含有少量代码,但它是极其灵活的。假设程序还要读取一系列用户ID与电子邮件地址,格式如下:

    fred fred@example.com bill bvoehno@plover.com warez warez-admin@plover.com ... ...

    可以复用read_config()并让它读取和解析这个文件,通过提供合适的分配表:

    $address_actions = { _DEFAULT_ => sub { my ($id, $addr, $act, $aref) = @_; push @$aref, [$id, $addr]; }, }; read_config($ADDRESS_FILE, $address_actions, \@address_array);

    这里已经给了read_config()一个非常小的分配表,它只有一个_DEFAULT_条目。read_config()对地址文件里的每一行都将调用这个默认的条目一次,传递给它“指示名称”(实际上即用户ID)与地址(即$rest的值)。默认的行为将获得这些信息并增加到@address_array,程序可以在以后使用它。

    相关资源:实战Nginx.取代Apache的高性能Web服务器

    最新回复(0)