编写Linux实用程序的艺术

发表于:2007-07-13来源:作者:点击数: 标签:
Linux 和其他类 UNIX 系统总是附带了大量的工具,它们执行从显而易见的到不可思议的广泛功能。类 UNIX 编程环境的成功很大程度上归功于工具的高品质和选择,以及这些工具之间相互衔接的简易性。 作为 开发 人员,您可能会发现现有实用程序并不总是能够解决问

    Linux 和其他类 UNIX 系统总是附带了大量的工具,它们执行从显而易见的到不可思议的广泛功能。类 UNIX 编程环境的成功很大程度上归功于工具的高品质和选择,以及这些工具之间相互衔接的简易性。

  作为开发人员,您可能会发现现有实用程序并不总是能够解决问题。虽然能够通过结合使用现有实用程序来容易地解决许多问题,然而解决其他问题却至少需要一些实 际的编程工作。这些后面的任务通常是创建新实用程序的候选任务,结合现有实用程序来创建新实用程序可以通过做最少的工作来解决问题。本文考察优秀实用程序所具有的品质,以及设计这种实用程序所经历的过程。

  优秀的实用程序具有哪些品质?

  Kernighan & Pike 所著的 The UNIX Programming Environment 一书中包含了对此问题的精彩讨论。优秀的实用程序是把自己的工作做得尽可能好的实用程序。它必须与其他实用程序配合融洽;必须能够容易地与其他实用程序结合使用。无法与其他实用程序结合使用的程序不是实用程序,而是应用程序。

  实用程序应该允许您根据手边的材料廉价而容易地构建一次性的应用程序。许多人认为实用程序就像是工具箱中的工具。设计实用程序的目标不是为了让单个工具来做所有事情,而是为了拥有一组工具,其中每个工具都尽可能好地做一件事情。

  有些实用程序自身就是相当有用的,而其他实用程序则必须与一系列实用程序配合使用。前者的例子包括 sort 和 grep。另一方面,xargs 除了与其他实用程序(最常见的是 find)配合使用外,很少单独使用。

  使用什么语言来编写实用程序?

  大多数 UNIX 系统实用程序都是用 C 语言来编写的。本文中的例子使用 Perl 和 sh。应该使用恰当的工具来做恰当的事情。如果您对某个实用程序使用得足够频繁,那么用编译型语言来编写它的成本也许能通过性能提升来获得回报。另一方面,对于程序的工作负荷很轻这种相当普遍的情况,使用脚本语言也许会提供更快的开发速度。

  如果无法肯定,您应该使用自己最了解的语言。至少当您在对某个实用程序进行原型化,或在弄清它是如何有用时,程序员效率将优先于性能调整。大多数 UNIX 系统实用程序都是用 C 编写的,这只是因为这些实用程序使用得足够频繁,以致考虑效率比考虑开发成本更加重要。Perl 和 sh(或 ksh)可能是用于快速原型化的很好语言。对于与其他程序配合实用的实用程序,使用 shell 来编写它们或许要比使用更传统的编程语言来编写它们要容易一些。另一方面,当您希望与原始的字节交互时,C 或许就是最好的选择。

    设计实用程序

  一个不错的经验法则就是当您第二次必须解决某个问题时,首先考虑实用程序的设计。不要对第一次编写的一次性作品感到遗憾;您可以将它看作是一个原型。第二次,请把您所需的功能与第一次所需的功能作比较。在第三次前后,您应该开始考虑花时间来编写一个通用实用程序。即使纯粹的重复性任务也可能会给实用程序的开发带来好处;例如,由于人们对尝试以通用的方式重命名文件感到失望,于是开发了许多通用文件重命名程序。

  下面是一些实用程序设计目标;每个目标将在下面单独的小节中介绍。

  做好一件事情;不要糟糕地做多件事情。关于做好一件事情的最佳例子或许是 sort。除了 sort 外,没有其他 哪个实用程序具有排序功能。基本的思想很简单:如果一次仅解决一个问题,您就能花时间把它解决好。

  设想一下,如果大多数程序都具有排序功能,但是有些仅支持按词法排序,而其他一些仅支持按数字排序,另外一些甚至支持关键字选择而不是对整行排序,那将是一件多么令人沮丧的事情。起码,这也是恼人的。

  当您发现某个问题需要解决时,应尝试将问题分解为多个部分,不要重复那些其他实用程序中已经存在的部分。您对允许配合现有工具使用的工具关注得越多,您的实用程序就越有可能保持有用。

  也许您需要编写多个程序。完成专门任务的最佳途径通常是编写一两个实用程序,再用一些线索将它们联系起来,而不是编写单个程序来解决整件事情。使用 20 行的 shell 脚本来将新的实用程序与现有工具结合起来是很理想的。如果尝试一次解决整个问题,随之而来的第一个变更就可能要求您全盘重新考虑。

  我偶尔需要从数据库生成两列或三列的输出。编写一个程序在单个列中生成输出,然后结合使用一个对输出进行分列的程序,这样通常会更有效率。组合这两个实用程序的 shell 脚本本身是临时性的,单独的实用程序比这个脚本的使用寿命更长。

  有些实用程序服务于非常专一的需要。针对一个包含大量内容的目录,如果 ls 的输出非常快地滚出屏幕,这可能是因为其中有一个文件具有非常长的文件名,从而迫使 ls 仅对输出使用单个列。使用 more 来对输出分页会花一些时间。为什么不像下面这样就按长度对行排序,然后通过 tail 来管道输出结果呢?

  清单 1. 世间能找到的最小实用程序 sl

#/usr/bin/perl -w
print sort { length $a <=> length $b } <>;


  清单 1 中的脚本确切地就做一件事情。它不接受任何选项,因为它不需要选项;它仅关心行的长度。归功于 Perl 便利的 <> 表达方式,这个小实用程序既适用于标准输入,也适用于命令行指定的文件。

    成为一个过滤器

  几乎所有实用程序都最适合想像为过滤器,尽管有一些非常有用的实用程序不符合这个模型。(例如,某个程序在执行计数时可能非常有用,尽管它作为过滤器工作得并不好。仅接受命令行参数作为输入并潜在地产生复杂输出的程序可能非常有用。)然而,大多数实用程序都应该作为过滤器来工作。根据惯例,过滤器对文本的行起作用。大多数过滤器都应该支持多个输入文件。

  记住实用程序需要在命令行和脚本中运行。有时,理想的行为会稍有不同。例如,大多数版本的 ls 都会在向终端写出时自动将输入排序到多个列中。grep 的默认行为是在指定多个文件的情况下打印从其中找到匹配项的那个文件名称。这样的差别应该与用户希望的实用程序工作方式有关,而不是与其他事项有关。例如,旧版本的 GNU bc 在启动时显示强迫性的版权标记。请不要那样做。让您的实用程序仅做它应该做的事情。

  实用程序喜欢生活在管道中。管道允许实用程序专注于自己的工作,而不是去关注旁枝末节。为了生活在管道中,实用程序需要从标准输入读取数据,然后向标准输出写出数据。如果您希望处理记录,那么您最好能够使每一行成为一个“记录”。诸如 sort 和 join 之类的现有程序已经在那样考虑了。它们将会因为您这样做而感谢您。

  我偶尔使用这样一个实用程序,它针对一个文件树反复调用其他程序。这充分利用了标准的 UNIX 实用程序过滤器模型,但是该模型仅适用于读取输入然后写出输出的实用程序;不能将它用于就地操作或接受输入输出文件名的实用程序。

  可以使用标准输入来运行的大多数程序也完全可以针对单个文件或一组文件运行。注意,可以证明这样违背了反对重复工作的规则;显而易见,这可以通过将 cat 的输出馈送给该系列中的下一个程序来解决。然而这在实践中似乎是合理的。

  有些程序可能合法地读取一种格式的记录,但是却产生完全不同的输出。这样的一个例子就是将输入材料划分为列的实用程序。这样一个实用程序可能将输入中的行视为记录,但是却在输出中的每行上产生多个记录。

  并非每个实用程序都完全符合这个模型。例如,xargs 不是接受记录而是接受文件名作为输入,并且所有的实际处理都是由其他程序完成的。

  通用化

  尝试将任务看作与您实际执行的任务类似;如果您能找出这些任务的通用描述,那么最好尝试编写一个符合该描述的实用程序。例如,如果您发现自己一天在根据词法对文本排序,而另一天在根据数字对文本排序,那么考虑编写一个通用排序实用程序也许是有意义的。

  对功能进行通用化有时会导致您发现:某个看起来似乎像单个实用程序的程序,实际上却是配合起来使用的两个实用程序。这很好。编写两个设计良好的实用程序可能要比编写一个丑陋的或复杂的实用程序更容易。

  做好一件事情并不意味着 仅仅 做一件事情。它意味着处理一致但有用的问题空间。许多人都使用 grep。然而,它的大量效用在于执行相关任务的能力。grep 的各种选项完成许多小实用程序的工作,如果这些工作都由单独的小实用程序来完成,最终会造成大量共享的、重复的代码。

  这条规则,以及做好一件事情的规则,都是一个根本原理的必然结果:无论何时都要尽可能避免代码重复。如果您编写半打程序,其中每个都对行排序,您最终可能必须六次修复六个类似的 bug,而不是去使用一个得到更好维护的 sort 程序。

  这是编写实用程序的一部分,即把大多数工作添加到完成该实用程序的过程中。您也许没有时间在最初就完全通用化一个实用程序,但是当您一直使用该实用程序就会获得相应的回报。

  有时,向某个程序添加相关功能是很有用的,即使这个功能并不是用来完成完全相同的任务。例如,当运行在终端设备上时,对原始二进制数据进行完美打印的程序可能更为有用,因为它使终端进入原始模式。这样使得测试涉及键盘映射、新键盘等的问题变得容易多了。不确定为什么当您按 delete 键时却得到代字号(~)吗? 这是弄清实际发送了什么内容的容易途径。这并不是完全相同的任务,但它足够类似,因而可能成为一个附加特性。

  清单 2 中的 errno 实用程序就是通用化的很好例子,因为它同时支持数字和符号名称。

 
  健壮
  实用程序的稳定性是很重要的。容易崩溃或无法处理真实数据的实用程序不是有用的实用程序。实用程序应该能够处理任意长度的行、巨型文件,等等。实用程序无法处理超过其内存容量的数据集或许是可以容忍的,但是有些实用程序不是这样;例如,sort 通过使用临时文件,一般能够对比其内存容量大得多的数据集排序。

  应该尽量确保弄清楚您的实用程序可能要操作哪些数据。不要简单地忽略无法处理的数据的可能性。应该检查这种情况并诊断您的实用程序。错误消息越明确,您对用户就越有帮助。尽量给用户提供足够的信息,以便让他们知道发生了什么情况以及如何解决。当处理数据文件时,尽量准确识别出不良的数据。当尝试解析数字时,不要简单地放弃;应该告诉用户您得到了什么数据,而且如果可能的话,还应该告诉用户该数据位于输入流中的哪一行上。

  作为一个很好的例子,请考虑 dc 的两种实现之间的区别。如果您运行 dc /home ,其中一种实现会显示“Cannot use directory as input!”而另一种实现只是无声地返回,没有错误消息,也没有不寻常的退出代码。当您错误地键入一个 cd 命令时,您更希望当前路径中有哪一种实现呢?类似地,如果您提供某个目录中的数据流(或许是执行 dc < /home),前者会给出详细的错误消息。另一方面,当它在获得无效数据的早期就选择放弃可能是理想的。

  安全漏洞经常植根于在意料之外的数据面前表现得不够健壮的程序中。务必记住,优秀的实用程序能够设法在 shell 脚本中作为根(root)用户身份运行。诸如 find 这样的程序中的缓冲区溢出可能会给大量的系统带来风险。

  程序对意料之外的数据处理得越好,它就更可能适应变化的环境。通常,设法使程序更健壮会导致您更好地理解该程序的作用,从而更好地使之通用化。

  新颖

  要编写的最糟糕的实用程序种类之一就是您已经有了的实用程序。我编写过一个名为 count 的美妙的实用程序。它允许我执行几乎任何计数任务。它是一个出色的实用程序,但是已经有一个名为 jot 的标准 BSD 实用程序做同样的事情。同样地,我的一个用于将数据转换为列的灵活的程序重复了一个现有实用程序 rs 的功能,这个实用程序同样可以在 BSD 系统上找到,只不过 rs 更灵活,设计得更好。请参阅下面的 参考资料 以了解关于 jot 和 rs 的更多信息。

  如果您即将开始编写一个实用程序,请花一点时间浏览一下各种系统,以确定那样的实用程序是否已经存在。不要害怕在 BSD 上借用 Linux 实用程序,或在 Linux 上借用 BSD 实用程序;实用程序代码的乐趣之一在于,几乎所有实用程序都具有很好的可移植性。

  不要忘了考察一下组合现有应用程序来形成一个实用程序的可能性。从理论上讲,组合现有程序来形成的实用程序运行得不足够快是可能的,但是编写一个新的实用程序很少会比等待一个稍慢的管道更快。

  一个例子实用程序

  从某种意义上,这个程序是一个可执行文件,因为对于作为过滤器来说,它决不会有任何用处。然而,它作为一个命令行实用程序却工作得非常好。

  这个程序仅做一件事情。它以近乎完美的输出格式输出 /usr/include/sys/errno.h 中的 errno 行。例如:

  $ errno 22
  EINVAL [22]: Invalid argument 
 
  清单 2. errno 查找器 


    #!/bin/sh
    usage() {
        echo >&2 'usage: errno [numbers or error names]\n'
        exit 1
    }
    for i
    do
        case '$i' in
        [0-9]*)
            awk '/^#define/ && $3 == ''$i'' {
                for (i = 5; i < NF; ++i) {
                    foo = foo ' ' $i;
                }
                printf('%-22s%s\n', $2 ' [' $3 ']:', foo);
                foo = ''
            }' < /usr/include/sys/errno.h
            ;;
        E*)
            awk '/^#define/ && $2 == '''$i''' {
                for (i = 5; i < NF; ++i) {
                    foo = foo ' ' $i;
                }
                printf('%-22s%s\n', $2 ' [' $3 ']:', foo);
                foo = ''
            }' < /usr/include/sys/errno.h
            ;;
        *)
 echo >&2 'errno: can't figure out whether '$i' is a name or a number.'
            usage
            ;;
        esac
    done

  这个程序通用化了吗?是的,非常理想。它同时支持数字和符号名称。另一方面,它不知道关于可能具有相同格式的其他文件的信息,比如 /usr/include/sys/signal.h。可以容易地扩展它来做到这点,但是对于这样一个便利的实用能够程序,简单地创建一个名为“signal”的拷贝来读取 signal.h,同时使用“SIG*”作为模式来匹配名称,这样会更容易。

  虽然这仅比对系统头文件使用 grep 方便一小点,但是它更不容易出错。它不会因为考虑不周的参数而产生无用的结果。另一方面,如果没有从头文件中找到给定的名称或数字,它不会产生诊断信息。它也不会费心去纠正某些输入错误。而且,由于命令行实用程序从来没有打算在自动化的环境中使用,因此它的上述特性无可非议。

  另一个例子可能是取消对输入排序的程序(请参阅 参考资料 以获得指向此实用程序的链接)。这相当简单;也就是读入输入文件,以某种方式存储它们,然后生成一个随机顺序来输出那些行。这是一个几乎具有无限应用前景的实用程序。编写这个实用程序也比编写排序程序容易得多;例如,您不需要指定您没有对哪些键排序,或者是您希望按字母顺序、词法顺序还是按数字顺序随机排序。棘手的部分在于读入可能非常长的行。事实上,上面提供的版本在搞欺骗;它假设所读入的行中没有空字节。纠正这个问题要困难多了,我在编写它时懒得去理会它。

  结束语

  如果您发现自己在重复执行某个任务,可以考虑编写一个程序来完成这个任务。如果事实证明该程序更通用化一点是合理的,那就通用化它,这样您就编写了一个实用程序。

  不要在您第一次需要某个实用程序的时候设计它。要等到您具有一些经验之后才着手设计。请随意地编写一两个原型;优秀的实用程序比糟糕的实用程序更能证明所花的时间和研究工作的价值。如果原先设想的出色实用程序最终却在您编写它之后成为无用之物,不要感到遗憾。如果您发现自己对新程序的缺点感到沮丧,您只需再执行另外一个原型化阶段。如果结果证明它是无用的,不奇怪,有时会发生这样的事情。

  您要寻求的是这样一个程序,它查找您的最初使用模式之外的通用应用。我编写 unsort 是因为,我希望找到一种从旧的 X11“rgb.txt”文件中获得随机颜色序列的容易途径。从那以后,我将它用于令人难以置信的大量任务中,这些任务都不是为了生成用于调试和基准排序例程的测试数据。

  优秀的实用程序能够为您在所有不很理想的作品上所花的时间带来回报。要做的下一件事情是使它对其他人可用,以便他们能够试验它。也要使您失败的尝试对其他人可用,也许其他人对某个实用程序具有您所不需要的用途。更重要的是,您的失败的实用程序也许是其他某个人的原型,从而给每个人带来一个美妙的实用程序。
 
 

原文转自:http://www.ltesting.net