使用 Rake 构建语言

Rake 是一种构建语言,其目的类似于 make 和 ant。与 make 和 ant 一样,它也是一种领域特定语言,但与它们不同的是,它是在 Ruby 语言中编写的内部 DSL。在这篇文章中,我介绍了 rake 并描述了一些我使用 rake 构建本网站时发现的有趣内容:依赖关系模型、合成任务、自定义构建例程和调试构建脚本。

2014 年 12 月 29 日



我已经使用 Ruby 多年了。我喜欢它简洁而强大的语法,以及它通常写得很好的库。几年前,我将我的大部分网站生成从 XSLT 转换为 Ruby,并且对这种改变感到非常满意。

如果你是我网站的常客,你不会感到惊讶,我的整个网站都是自动构建的。我最初使用 ant(Java 世界中流行的构建环境)来完成这项工作,因为它与 Java XSL 处理器很匹配。随着我越来越多地使用 Ruby,我更多地使用了 Rake,这是一种基于 Ruby 的构建语言,由 Jim Weirich 开发。最近,我完全替换了构建过程,删除了所有 ant,转而使用 Rake。

在我最初使用 Rake 的时候,我使用它的方式类似于我使用 ant 的方式。然而,在这次尝试中,我试图以不同的方式做事,以探索 Rake 的一些有趣功能。因此,我认为我应该写这篇文章来深入探讨其中的一些领域。Rake 是我的第三种构建语言。我多年前使用过 make(并且已经忘记了很多)。在过去六七年里,我使用过很多 ant。Rake 拥有这些语言的许多功能,以及一些对我来说是新花样的功能。虽然 Rake 是用 Ruby 编写的,并且大量使用了这种语言,但你可以将它用于任何自动构建处理。对于简单的构建脚本,你不需要了解 Ruby,但是一旦事情变得更加有趣,你就需要了解 Ruby 才能让 Rake 做出最好的事情。

这是一个有点偏颇的故事。我并不是试图写一篇关于 Rake 的教程——我将专注于我发现有趣的事情,而不是给出完整的覆盖范围。我不会假设你了解 Ruby、Rake 或任何其他构建语言。我会在讲解过程中解释相关的 Ruby 部分。希望如果你对这些东西有所了解,或者只是对不同的计算模型感兴趣,你会发现这篇文章值得一读。

基于依赖关系的编程

等等——在上一段中我说“不同的计算模型”。这对于构建语言来说是不是一个太宏大的词语?不,不是。我使用过的所有构建语言(make、ant(Nant)和 rake)都使用基于依赖关系的计算风格,而不是通常的命令式风格。这让我们对如何对它们进行编程有了不同的思考方式。大多数人不会这样想,因为大多数构建脚本都很短,但实际上这是一个非常深刻的区别。

现在举个例子。让我们想象一下,我们想要编写一个程序来构建一个项目。这个构建有几个不同的步骤。

  • CodeGen:获取数据配置文件,并使用它们生成数据库结构和访问数据库的代码。
  • Compile:编译应用程序代码。
  • DataLoad:将测试数据加载到数据库中。
  • Test:运行测试。

我们需要能够独立运行这些任务中的任何一个,并确保一切正常。在执行所有之前的步骤之前,我们不能进行测试。Compile 和 DataLoad 需要先运行 CodeGen。我们如何表达这些规则?

如果我们用命令式风格来做,它看起来像这样,将每个任务作为一个 ruby 过程。

# this is comment in ruby
def codeGen  #def introduces a procedure or method
  # do code gen stuff
end

def compile
  codeGen
  # do compile stuff
end

def dataLoad
  codeGen
  # do data load stuff
end

def test
  compile
  dataLoad
  #run tests
end

注意这里有一个问题。如果我调用 test,我会执行两次 codeGen 步骤。这不会导致错误,因为 codeGen 步骤是(我假设)幂等的——也就是说,多次调用它与调用它一次没有什么不同。但它会花费时间,而构建很少有时间浪费。

为了解决这个问题,我可以将这些步骤分成公共部分和内部部分,如下所示

def compile
  codeGen
  doCompile
end

def doCompile
  # do the compile
end

def dataLoad
  codeGen
  doDataLoad
end

def doDataLoad
  #do the data load stuff
end

def test
  codeGen
  doCompile
  doDataLoad
  #run the tests
end

这可以工作,但有点乱。这也是依赖关系系统如何提供帮助的完美例子。在命令式模型中,每个例程都会调用例程中的步骤。在基于依赖关系的系统中,我们有任务并指定先决条件(它们的依赖关系)。当你调用一个任务时,它会查看是否有先决条件,然后安排一次调用每个先决条件任务。因此,我们简单的例子看起来像这样。

task :codeGen do
  # do the code generation
end

task :compile => :codeGen do
  #do the compilation
end

task :dataLoad => :codeGen do
  # load the test data
end

task :test => [:compile, :dataLoad] do
  # run the tests
end

(希望你能感觉到它在说什么,我一会儿会解释语法。)

现在,如果我调用 compile,系统会查看 compile 任务,发现它依赖于 codeGen 任务。然后它会查看 codeGen 任务,发现没有先决条件。因此,它会先运行 codeGen,然后运行 compile。这与命令式情况相同。

当然,有趣的情况是 test。在这里,系统会发现 compile 和 dataLoad 都依赖于 codeGen,因此它会安排任务,以便 codeGen 首先运行,然后运行 compile 和 dataload(以任何顺序),最后运行 test。本质上,任务的实际运行顺序是在运行时由执行引擎确定的,而不是在设计时由编写构建脚本的程序员决定的。

这种基于依赖关系的计算模型非常适合构建过程,这就是为什么所有三种语言都使用它的原因。用任务和依赖关系来思考构建是自然的,构建中的大多数步骤都是幂等的,我们真的不想让不必要的工作拖慢构建速度。我怀疑很少有人在编写构建脚本时意识到他们正在使用一种奇特的计算模型,但事实就是这样。

构建的领域特定语言

我使用的所有三种构建语言都共享另一个特征——它们都是 领域特定语言 (DSL) 的例子。但是它们是不同类型的 DSL。在我之前使用过的术语中

  • make 是使用自定义语法的外部 DSL
  • ant(和 nant)是使用基于 XML 的语法的外部 DSL
  • rake 是使用 Ruby 的内部 DSL。

rake 是通用语言的内部 DSL 的事实,是它与其他两种语言之间非常重要的区别。它本质上允许我在需要时使用 Ruby 的全部功能,代价是必须做一些奇怪的事情来确保 rake 脚本是有效的 ruby。由于 ruby 是一种不显眼的语言,所以语法上的奇异之处并不多。此外,由于 ruby 是一种完整的语言,我不需要退出 DSL 来做有趣的事情——这在使用 make 和 ant 时一直是一个经常遇到的挫折。事实上,我开始认为构建语言实际上非常适合内部 DSL,因为你确实需要那种完整的语言功能,只是经常需要它,以至于值得这样做——而且你不会看到很多非程序员编写构建脚本。

Rake 任务

Rake 定义了两种类型的任务。常规任务类似于 ant 中的任务,文件任务类似于 make 中的任务。如果你对这些都不了解,别担心,我马上就会解释。

常规任务是最简单的解释。这是我用于测试环境的构建脚本中的一个例子。

task :build_refact => [:clean] do
  target = SITE_DIR + 'refact/'
  mkdir_p target
  require 'refactoringHome'
  OutputCapturer.new.run {run_refactoring}
end
 

第一行定义了任务的大部分内容。在这种语言中,task 实际上是一个关键字,用于引入任务定义。:build_refact 是任务的名称。命名它的语法有点奇怪,因为我们需要以冒号开头,这是作为内部 DSL 的结果之一。

在任务名称之后,我们继续进行先决条件。这里只有一个,:clean。语法是 => [:clean]。我们可以在方括号内列出多个依赖项,用逗号隔开。从前面的例子中可以看出,如果只有一个任务,我们不需要方括号。如果没有依赖项(或者出于其他原因——这里面有一个很有趣的话题,我稍后会讲到),我们根本不需要依赖项。

为了定义任务的主体,我们在doend 之间编写 ruby 代码。在这个块中,我们可以放置任何有效的 ruby 代码——我不会在这里解释这段代码,因为你不需要理解它就能了解任务是如何工作的。

rake 脚本(或者 ruby 程序员称之为 rakefile)的好处是你可以很清楚地把它读成构建脚本。如果我们要用 ant 编写等效的代码,它看起来像这样

<target name = "build_refact" depends = "clean">
<-- define the task -->
</target>

现在你可以把它看作一个 DSL 并遵循它,但由于它是一个内部 DSL,你可能对它如何作为有效的 ruby 代码工作感兴趣。实际上,task 不是一个关键字,而是一个例程调用。它接受两个参数。

第一个参数是一个哈希(相当于映射或字典)。Ruby 有一种特殊的语法来表示哈希。一般来说,语法是 {key1 => value1, key2 => value2}。但是,如果只有一个哈希,则花括号是可选的,因此在定义 rake 任务时不需要它们,这有助于简化 DSL。那么键和值是什么?这里的键是一个符号——在 ruby 中由前导冒号标识。你可以使用其他字面量,我们很快就会看到字符串,你也可以使用变量和常量——我们很快就会发现它们非常方便。值是一个数组——它实际上相当于其他语言中的列表。这里我们列出了其他任务的名称。如果我们不使用方括号,我们只有一个值而不是一个列表——rake 可以处理数组或单个字面量——它非常包容,我必须说。

那么第二个参数在哪里?它位于doend 之间——一个块——ruby 对 闭包 的称呼。因此,当 rake 文件运行时,它会构建这些任务对象的图形,通过依赖关系链接相互连接,每个对象都有一个块,在适当的时候执行。一旦所有任务都创建完毕,rake 系统就可以使用依赖关系链接来确定哪些任务需要运行以及运行顺序,然后它就会执行这些操作,按适当的顺序调用每个任务的块。闭包的一个关键属性是它们不需要在评估时执行,它们可以保存到以后执行——即使它们引用了在块实际执行时不在作用域内的变量。

这里的问题是,我们看到的是合法的 ruby 代码,虽然排列方式很奇怪。但这种奇怪的方式让我们拥有了一个非常易读的 DSL。Ruby 还通过拥有非常少的语法来提供帮助——即使是一些小细节,比如不需要为过程参数使用括号,也有助于使 DSL 保持紧凑。闭包也是必不可少的——就像它们在编写内部 DSL 时经常做的那样,因为它们允许我们将代码打包在替代的控制结构中。

文件任务

我上面提到的任务类似于 ant 中的任务。Rake 还支持一种稍微不同的任务类型,称为文件任务,它更接近于 make 中的任务概念。这是另一个例子,稍微简化了一下,来自我的网站 rakefile。

file 'build/dev/rake.html' => 'dev/rake.xml' do |t|
  require 'paper'
  maker = PaperMaker.new t.prerequisites[0], t.name
  maker.run
end

当您提到文件时,实际上指的是实际的文件,而不是任务名称。因此,“build/dev/rake.html”和“dev/rake.xml”是实际的文件。html 文件是此任务的输出,而 xml 文件是输入。您可以将文件任务视为告诉构建系统如何创建输出文件——实际上,这正是 make 中的概念——您列出所需的输出文件,并告诉 make 如何创建它们。

文件任务的一个重要部分是,除非您需要运行它,否则它不会运行。构建系统会查看文件,并且仅当输出文件不存在或其修改日期早于输入文件时才运行任务。因此,当您以逐文件的方式考虑事物时,文件任务非常有效。

此任务的一个不同之处在于,我们将任务对象本身作为参数传递到闭包中——这就是 |t| 的作用。我们现在可以在闭包中引用任务对象并调用其方法。我这样做是为了避免重复文件的名称。我可以使用 t.name 获取任务的名称(即输出文件)。类似地,我可以使用 t.prerequisites 获取先决条件列表。

Ant 没有等效于文件任务,而是每个任务都自行执行相同类型的必要性检查。XSLT 转换任务接受一个输入文件、样式文件和输出文件,并且仅当输出文件不存在或比任何输入文件都旧时才运行转换。这只是将此检查的责任放在哪里——是在构建系统中还是在任务中。Ant 主要使用用 java 编写的预制任务,make 和 rake 都依赖于构建编写者为任务编写代码。因此,减轻任务编写者检查事物是否最新的负担更有意义。

但是,在 rake 任务中执行最新检查实际上非常容易。这就是它的样子。

task :rakeArticle do
  src = 'dev/rake.xml'
  target = 'build/dev/rake.html'
  unless uptodate?(target, src) 
    require 'paper'
    maker = PaperMaker.new src, target 
    maker.run
  end
end

Rake 通过 fileutils 包提供了一系列简单的类 Unix 命令,用于文件操作,例如 cp, mv, rm, 等。它还提供了 uptodate?,非常适合此类检查。

因此,我们在这里看到了两种做事方式。我们可以使用文件任务,也可以使用带有 uptodate? 的常规任务来决定是否执行操作——我们应该选择哪一个?

我必须承认,我对这个问题没有很好的答案。两种策略似乎都运行良好。我决定在我的新 rakefile 中做的是,尽可能地推动细粒度的文件任务。我这样做不是因为我知道这是最好的做法,而是主要为了看看结果会怎样。当您遇到新事物时,过度使用它通常是一个好主意,以便找出它的边界。这是一个相当合理的学习策略。这也是人们在早期总是倾向于过度使用新技术或技巧的原因。人们经常批评这一点,但这是学习的自然过程。如果您不将某件事推到其有用性的边界之外,您如何找到该边界在哪里?重要的是在一个相对受控的环境中这样做,这样您就可以在找到边界时修复问题。(毕竟,在我们尝试之前,我认为 XML 将是构建文件的良好语法。)

我还要说,到目前为止,我还没有发现将文件任务和细粒度任务推得太远有任何问题。我可能在一两年后会改变想法,但到目前为止,我很满意。

反向定义依赖关系

到目前为止,我主要讨论了 rake 如何执行与您在 ant 和 make 中发现的类似操作。这是一个很好的组合——将两种功能与 ruby 的全部功能结合起来——但这本身并不会给我太多理由写这篇文章。让我感兴趣的是 rake 做的一些特别的事情(以及允许的事情),这些事情有点不同。第一个是允许在多个地方指定依赖项。

在 ant 中,您通过将依赖项声明为依赖任务的一部分来定义依赖项。到目前为止,在我的 rake 示例中我也这样做过,如下所示。

task :second => :first do
  #second's body
end

task :first do
  #first's body
end

Rake(像 make 一样)允许您在最初声明任务后向任务添加依赖项。实际上,它允许您在多个地方继续讨论任务。这样,我可以决定在添加先决条件任务时添加依赖项,如下所示。

task :second do
  #second's body
end

task :first do
  #first's body
end
task :second => :first 

当任务在构建文件中彼此相邻时,这不会有太大区别,但在较长的构建文件中,它确实增加了一点灵活性。本质上,它允许您以通常的方式考虑依赖项,或者在添加先决条件任务时添加它们,或者实际上将它们放在与两者都无关的第三个位置。

像往常一样,这种灵活性带来了新的问题,在何处定义依赖项最佳?我还没有确定的答案,但在我的构建文件中,我使用了两个经验法则。当我考虑一项需要在执行另一项任务之前完成的任务时,我在编写依赖任务时以传统方式定义了依赖项。但是,我经常使用依赖项将相关任务分组在一起,例如各种勘误页。当使用依赖项进行分组(构建文件结构的常见部分)时,将依赖项放在先决条件任务旁边似乎很有意义。

task :main => [:errata, :articles]

#many lines of build code

file 'build/eaaErrata.html' => 'eaaErrata.xml' do
  # build logic
end
task :errata => 'build/eaaErrata.html'
    

我实际上不必使用 task 关键字定义 :errata 任务,只需将其作为 :main 的依赖项就足以定义任务。然后,我可以在以后添加各个勘误文件,并将每个文件添加到组任务中。对于这种组行为,这似乎是一种合理的方式(尽管我在我的构建文件中并没有完全这样做,正如我们将在后面看到的那样。)

由此引发的一个问题是“当依赖项分散在整个构建文件中时,我们如何找到所有依赖项?”这是一个好问题,但答案是让 rake 告诉你,你可以使用 rake -P 来做到这一点,它会打印出每个任务及其先决条件。

合成任务

允许您在定义任务后添加依赖项,以及拥有可用的完整 ruby,为构建引入了一些进一步的技巧。

但是,在解释合成任务之前,我需要介绍一些关于构建过程的重要原则。构建脚本往往需要执行两种类型的构建——干净构建和增量构建。干净构建发生在您的输出区域为空时,在这种情况下,您从其(版本控制的)源代码构建所有内容。这是构建文件可以做的最重要的工作,首要任务是拥有正确的干净构建。

干净构建很重要,但它们确实需要时间。因此,执行增量构建通常很有用。在这里,您已经在输出目录中拥有了一些东西。增量构建需要弄清楚如何以最少的工作量使您的输出目录与最新的源代码保持最新。这里可能发生两种错误。第一种(也是最严重的)是缺少重建——这意味着某些应该构建的项目没有构建。这非常糟糕,因为它会导致输出与输入不匹配(特别是对输入进行干净构建的结果)。较小的错误是不必要的重建——这会构建不需要构建的输出元素。这是一个不太严重的错误,因为它不是正确性错误,但它是一个问题,因为它会增加增量构建的时间。除了时间之外,它还会增加混乱——当我运行我的 rake 脚本时,我希望只看到已更改的内容被构建,否则我会想“为什么它会改变?”

安排良好依赖关系结构的很大一部分是为了确保增量构建正常工作。我想通过执行“rake”来执行我的网站的增量构建——调用默认任务。我希望它只构建我想要的东西。

所以这是我的需求,一个有趣的问题是如何让它为我的 bliki 工作。我的 bliki 的源代码是我的 bliki 目录中的一堆 xml 文件。输出是每个条目一个输出文件,加上几个摘要页——其中 bliki 主页是最重要的。我需要的是,对源文件的任何更改都会重新触发 bliki 构建。

我可以通过以下方式命名所有文件来做到这一点。

BLIKI = build('bliki/index.html')

file BLIKI => ['bliki/SoftwareDevelopmentAttitude.xml',
               'bliki/SpecificationByExample.xml',
               #etc etc
              ] do
  #logic to build the bliki
end

def build relative_path
 # allows me to avoid duplicating the build location in the build file
 return File.join('build', relative_path)
end

但显然这会非常乏味,而且只是让我在想要添加文件时忘记将新文件添加到列表中。幸运的是,我可以通过以下方式做到这一点。

BLIKI = build('bliki/index.html')

FileList['bliki/*.xml'].each do |src|
  file BLIKI => src
end

file BLIKI do 
  #code to build the bliki
end

FileList 是 rake 的一部分,它将根据传入的 glob 生成文件列表——这里它创建了 bliki 源目录中所有文件的列表。each 方法是一个内部迭代器,它允许我循环遍历它们并将每个文件作为依赖项添加到文件任务中。(each 方法是一个 集合闭包方法。)

我对 bliki 任务做的另一件事是为它添加一个符号任务。

desc "build the bliki"
task :bliki => BLIKI

我这样做是为了能够只使用 rake bliki 构建 bliki。我不确定我是否真的需要它。如果所有依赖项都设置正确(现在就是这样),我只需执行默认的 rake,就不会有不需要的重建。但我暂时保留了它。desc 方法允许您为以下任务定义一个简短的描述,这样当我运行 rake -T 时,我会得到一个包含所有为其定义了 desc 的任务的列表。这是一种查看哪些目标可供我使用的有用方法。

如果您以前使用过 make,您可能会认为这让人想起 make 最伟大的功能之一——能够指定模式规则来自动创建某些类型的文件。常见的示例是,您希望通过对相应的 foo.c 文件运行 C 编译器来构建任何 foo.o 文件。

%.o : %.c
        gcc $< -o $@

%.c 将匹配所有以“.c”结尾的文件。$< 指的是源(先决条件),$@ 指的是规则的目标。此模式规则意味着您不必在项目中列出每个文件及其编译规则,而是模式规则告诉 make 如何构建它需要的任何 *.o 文件。(实际上,您甚至不需要在 make 文件中使用它,因为 make 附带了许多这样的模式规则。)

Rake 实际上有一个类似的机制。我不会谈论它,只是提一下它存在,因为我还没有发现我需要它。合成任务满足了我所有的需求。

块范围任务

我在使用文件名和依赖项时发现的一个问题是,您必须重复文件名。以这个例子为例。

file 'build/articles/mocksArentStubs.html' => 'articles/mock/mocksArentStubs.xml' do |t|
 transform t.prerequisites[0], t.name
end
task :articles => 'build/articles/mocksArentStubs.html'

在上面的示例中,“build/articles/mocksArentStubs.html”在代码中出现了两次。我可以通过使用任务对象来避免在操作块中重复,但我必须重复它来设置对总体 articles 任务的依赖关系。我不喜欢这种重复,因为如果我更改文件名,它会导致麻烦。我需要一种方法来定义它一次。我可以简单地声明一个常量,但随后我声明了一个常量,该常量在我的 rakefile 中随处可见,而我实际上只在这一部分中使用它。我喜欢变量作用域尽可能小。

我可以通过使用上面提到的 FileList 类来解决这个问题,但这次我仅使用单个文件。

FileList['articles/mock/mocksArentStubs.xml'].each do |src|
  target = File.join(BUILD_DIR + 'articles', 'mocksArentStubs.html')
  file target => src do
    transform src, target
  end
  task :articles => target
end

这样,我定义了 src 和 target 变量,它们只在代码块内有作用域。请注意,这只有在我从 :articles 任务中定义依赖项时才对我有帮助。如果我想在 :articles 任务的定义中定义依赖项,我将需要一个常量,以便在整个 rakefile 中获得可见性。

当 Jim Weirich 阅读本文的草稿时,他指出,如果您发现 FileList 语句太冗长,您可以轻松地定义一个专门用于执行此操作的方法

  def with(value)
    yield(value)
  end

然后执行

  with('articles/mock/mocksArentStubs.xml') do |src|
    # whatever
  end

构建方法

将构建语言作为完整编程语言的内部 DSL 的一个真正好处是,我可以编写例程来处理常见情况。子例程是构建程序的最基本方法之一,缺乏方便的子例程机制是 ant 和 make 的一大问题——尤其是当您遇到更复杂的构建时。

这里有一个我常用的常见构建例程的示例——使用 XSLT 处理器将 XML 文件转换为 HTML。我所有较新的写作都使用 ruby 来进行这种转换,但我有很多旧的 XSLT 代码,而且我看不出有什么急于改变的理由。在编写了各种处理 XSLT 的任务后,我很快发现了一些重复,所以我定义了一个例程来完成这项工作。

def xslTask src, relativeTargetDir, taskSymbol, style
  targetDir = build(relativeTargetDir)
  target = File.join(targetDir, File.basename(src, '.xml') + '.html')
  task taskSymbol => target
  file target => [src] do |t|
    mkdir_p targetDir
    XmlTool.new.transform(t.prerequisites[0], t.name, style)
  end
end    

前两行确定目标目录和目标文件。然后,我将目标文件作为依赖项添加到提供的任务符号中。然后,我创建一个新的文件任务,其中包含创建目标目录(如果需要)的指令,并使用我的 XmlTool 来执行 XSLT 转换。现在,当我想要创建一个 XSLT 任务时,我只需调用此方法。

xslTask 'eaaErrata.xml', '.', :errata, 'eaaErrata.xsl'

此方法很好地封装了所有通用代码,并根据我当前的需求对变量进行了参数化。我发现将父组任务传递到例程中非常有用,这样例程就可以轻松地为我构建依赖项——这是灵活指定依赖项的另一个优势。我有一个类似的通用任务,用于将文件直接从源目录复制到构建目录,我将其用于图像、pdf 等。

def copyTask srcGlob, targetDirSuffix, taskSymbol
  targetDir = File.join BUILD_DIR, targetDirSuffix
  mkdir_p targetDir
  FileList[srcGlob].each do |f|
    target = File.join targetDir, File.basename(f)
    file target => [f] do |t|
      cp f, target
    end
    task taskSymbol => target
  end
end

copyTask 稍微复杂一些,因为它允许我指定要复制的文件的 glob,这使我可以复制以下内容

copyTask 'articles/*.gif', 'articles', :articles

这会将我源代码的 articles 子目录中的所有 gif 文件复制到我构建目录的 articles 目录中。它为每个文件创建一个单独的文件任务,并将它们全部作为 :articles 任务的依赖项。

平台相关的 XML 处理

当我使用 ant 构建我的网站时,我使用了基于 java 的 XSLT 处理器。当我开始使用 rake 时,我决定切换到本地 XSLT 处理器。我使用 Windows 和 Unix(Debian 和 MacOS)系统,这两个系统都容易获得 XSLT 处理器。当然,它们是不同的处理器,我需要以不同的方式调用它们——但当然我希望这对于 rakefile 来说是隐藏的,当然对于我调用 rake 时也是隐藏的。

这里再次体现了直接使用完整语言的好处。我可以轻松地编写一个使用平台信息来执行正确操作的 Xml 处理器。

我从工具的接口部分——XmlTool 类开始。

class XmlTool
  def self.new
    return XmlToolWindows.new if windows?
    return XmlToolUnix.new
  end
  def self.windows?
    return RUBY_PLATFORM =~ /win32/i 
  end
end

在 ruby 中,你可以通过调用类上的 new 方法来创建对象。与专制的构造函数相比,这最大的好处是你可以覆盖这个 new 方法——甚至可以返回不同类的对象。因此,在这种情况下,当我调用 XmlTool.new 时,我不会得到 XmlTool 的实例——而是会得到适合我运行脚本的平台的正确工具类型。

这两个工具中最简单的是 Unix 版本。

class XmlToolUnix
  def transform infile, outfile, stylefile
    cmd = "xsltproc #{stylefile} #{infile} > #{outfile}"
    puts 'xsl: ' + infile
    system cmd
  end
  def validate filename
    result = `xmllint -noout -valid #{filename}`
    puts result unless  '' == result
  end
end

你会注意到这里有两个用于 XML 的方法,一个用于 XSLT 转换,另一个用于 XML 验证。对于 unix,每个方法都会调用一个命令行调用。如果你不熟悉 ruby,请注意使用 #{variable_name} 结构将变量插入字符串的便捷功能。实际上,你可以在其中插入任何 ruby 表达式的结果——这非常方便。在 validate 方法中,我使用反引号——它执行命令行并返回结果。puts 命令是 ruby 打印到标准输出的方式。

Windows 版本稍微复杂一些,因为它需要使用 COM 而不是命令行。

class XmlToolWindows
  def initialize
    require 'win32ole'
  end
  def transform infile, outfile, stylefile
    #got idea from http://blog.crispen.org/archives/2003/10/24/lessons-in-xslt/
    input = make_dom infile
    style = make_dom stylefile
    result = input.transformNode style
    raise "empty html output for #{infile}" if result.empty?
    File.open(outfile, 'w') {|out| out << result}
  end
  def make_dom filename, validate = false
    result = WIN32OLE.new 'Microsoft.XMLDOM'
    result.async = false
    result.validateOnParse = validate
    result.load filename
    return result
  end
  def validate filename
    dom = make_dom filename, true
    error = dom.parseError
    unless error.errorCode == 0
      puts "INVALID: code #{error.errorCode} for  #{filename} " + 
        "(line #{error.line})\n#{error.reason}"
    end
  end
end

语句 require 'win32ole' 会引入用于处理 Windows COM 的 ruby 库代码。请注意,这是程序的常规部分;在 ruby 中,你可以设置程序,以便仅在需要且存在时加载库。然后,我可以像使用任何其他脚本语言一样操作 COM 对象。

你会注意到这三个 XML 处理类之间没有类型关系。xml 操作之所以有效,是因为 Windows 和 Unix XmlTools 都实现了 transform 和 validate 方法。这就是 rubyist 所谓的 鸭子类型——如果它像鸭子一样行走,像鸭子一样嘎嘎叫,那么它一定是鸭子。没有编译时检查来确保这些方法存在。如果方法不正确,它会在运行时失败——这应该通过测试来消除。我不会详细讨论动态类型检查与静态类型检查的争论,只是指出这是一个使用鸭子类型的示例。

如果你使用的是 Unix 系统,你可能需要使用你拥有的任何包管理系统来查找和下载我正在使用的 Unix xml 命令(在 Mac 上我使用 Fink)。XMLDOM DLL 通常与 Windows 一起提供,但同样取决于你的设置,你可能需要下载它。

变得糟糕

关于编程,你可以保证的一件事是,事情总是会出错。无论你多么努力,总有一些你认为自己说的话和计算机听到的话之间的不匹配。看看这段 rake 代码(从实际发生在我身上的代码简化而来)。

src = 'foo.xml'
target = build('foo.html')
task :default => target
copyTask 'foo.css', '.', target
file target => src do
  transform src, target
end

看到错误了吗?我也没有。我知道的是,构建 build/foo.html 的转换总是会发生,即使它不需要——不必要的重建。我不知道为什么。两个文件的时间戳都是正确的,即使我确保目标文件比源文件晚,我仍然会得到重建。

我的第一个调查步骤是使用 rake 的跟踪功能(rake --trace)。通常情况下,它是我识别奇怪调用的唯一方法,但这次它一点帮助都没有。它只是告诉我 'build/foo.html' 任务正在执行——但它没有说明原因。

此时,人们可能会倾向于责怪 Jim 没有提供调试工具。也许咒骂至少会让我感觉好些:“你妈妈是来自克利夫兰的母狼,你爸爸是一块湿萝卜”。

但我有一个更好的选择。Rake 是 ruby,任务只是对象。我可以获取对这些对象的引用并对其进行询问。Jim 可能没有将此调试代码放入 rake 中,但我可以轻松地自己添加它。

class Task 
  def investigation
    result = "------------------------------\n"
    result << "Investigating #{name}\n" 
    result << "class: #{self.class}\n"
    result <<  "task needed: #{needed?}\n"
    result <<  "timestamp: #{timestamp}\n"
    result << "pre-requisites: \n"
    prereqs = @prerequisites.collect {|name| Task[name]}
    prereqs.sort! {|a,b| a.timestamp <=> b.timestamp}
    prereqs.each do |p|
      result << "--#{p.name} (#{p.timestamp})\n"
    end
    latest_prereq = @prerequisites.collect{|n| Task[n].timestamp}.max
    result <<  "latest-prerequisite time: #{latest_prereq}\n"
    result << "................................\n\n"
    return result
  end
end

这里有一些代码,可以让你看到这一切应该是什么样子。如果你不是 rubyist,你可能会发现看到我实际上向 rake 中的任务类添加了一个方法很奇怪。这种事情,与面向方面的引入相同,在 ruby 中是完全合法的。像许多 ruby 东西一样,你可以想象这个功能会带来混乱,但只要你小心,它真的很好。

现在我可以调用它来查看更多关于正在发生的事情的信息

src = 'foo.xml'
target = build('foo.html')
task :default => target
copyTask 'foo.css', '.', target
file target => src do |t|
  puts t.investigation
  transform src, target
end

我得到了以下打印输出

------------------------------
Investigating build/foo.html
class: Task
task needed: true
timestamp: Sat Jul 30 16:23:33 EDT 2005
pre-requisites:
--foo.xml (Sat Jul 30 15:35:59 EDT 2005)
--build/./foo.css (Sat Jul 30 16:23:33 EDT 2005)
latest-prerequisite time: Sat Jul 30 16:23:33 EDT 2005
................................

起初,我对时间戳感到疑惑。输出文件的时间戳是 16:42,那么为什么任务显示 16:23 呢?然后我意识到任务的类是 Task 而不是 FileTask。Task 不执行日期检查,如果你调用它们,它们将始终运行。所以我尝试了这个。

src = 'foo.xml'
target = build('foo.html')
file target
task :default => target
copyTask 'foo.css', '.', target
file target => src do |t|
  puts t.investigation 
  transform src, target
end

变化在于,我在任务在其他任务上下文中被提及之前,将其声明为文件任务。这解决了问题。

从中学到的教训是,使用这种内部 DSL,你可以查询对象结构以了解发生了什么。当发生像这样的奇怪事情时,这非常方便。我在另一个案例中使用了这种方法,当时我遇到了不必要的构建——打开引擎盖看看究竟发生了什么非常有用。

(顺便说一下,如果输出文件尚不存在,例如在干净构建中,我的 investigation 方法会失效。我没有花任何精力去修复它,因为我只在文件已经存在时才需要它。)

自从我写了这段代码以来,Jim 在 rake 本身中添加了一个调查方法,与这个方法非常接近。因此,你不再需要像我在这里做的那样。但总体原则仍然适用——如果 rake 没有做你想做的事情,你可以进入并修改它的行为。

使用 Rake 构建非 Ruby 应用程序

虽然 rake 是用 ruby 编写的,但没有理由不能使用它来构建用其他语言编写的应用程序。任何构建语言都是用于构建东西的脚本语言,你可以使用用另一种语言编写的工具来构建一个环境。(一个很好的例子是,当我们使用 ant 来构建一个 Microsoft COM 项目时,我们只需要将其隐藏在 Microsoft 顾问面前。)rake 的唯一问题是,为了执行更高级的操作,你需要了解 ruby,但我一直认为,任何专业程序员都需要了解至少一种脚本语言,以便完成各种奇奇怪怪的任务。

运行测试

Rake 的库允许你使用 TestTask 类直接在 rake 系统中运行测试

require 'rake/testtask'
Rake::TestTask.new do |t|
  t.libs << "lib/test"
  t.test_files = FileList['lib/test/*Tester.rb']
  t.verbose = false
  t.warning = true
end

默认情况下,这将创建一个 :test 任务,该任务将运行给定文件中的测试。你可以使用多个任务对象为不同的情况创建测试套件。

默认情况下,测试任务将运行所有给定文件中的所有测试。如果你只想运行单个文件中的测试,你可以使用

        rake test TEST=path/to/tester.rb
      

如果你想运行名为“test_something”的单个测试,你需要使用 TESTOPTS 传递选项给测试运行器。

         rake test TEST=path/to/tester.rb TESTOPTS=--name=test_something
      

我经常发现为运行特定测试创建临时 rake 任务很有帮助。要运行一个文件,我可以使用

Rake::TestTask.new do |t|
  t.test_files = FileList['./testTag.rb']
  t.verbose = true
  t.warning = true
  t.name = 'one'
end

要运行一个测试方法,我需要添加测试选项

Rake::TestTask.new do |t|
  t.test_files = FileList['./testTag.rb']
  t.verbose = true
  t.warning = true
  t.name = 'one'
  t.options = "--name=test_should_rebuild_if_not_up_to_date"
end

文件路径操作

Rake 扩展了字符串类,以执行一些有用的文件操作表达式。例如,如果你想通过获取源文件并更改文件扩展名来指定目标文件,你可以这样做

"/projects/worldDominationSecrets.xml".ext("html")
# => '/projects/worldDominationSecrets.html'

对于更复杂的操作,有一个 pathmap 方法,它使用模板标记,类似于 printf 的风格。例如,模板 "%x" 指的是路径的文件扩展名,"%X" 指的是除文件扩展名之外的所有内容,因此我可以这样编写上面的示例。

"/projects/worldDominationSecrets.xml".pathmap("%X.html")
# => '/projects/worldDominationSecrets.html'

另一个常见的情况是将来自 'src' 的东西放到 'bin' 中。为此,我们可以使用 "%{pattern,replacement}X" 对模板中的元素进行替换,例如

"src/org/onestepback/proj/foo.java".pathmap("%{^src,bin}X.class")
# => "bin/org/onestepback/proj/foo.class"

你可以在 Rake 的 String.pathmap 文档 中找到路径操作方法的完整列表。

我发现这些方法非常有用,因此我喜欢在自己的代码中进行文件路径操作时使用它们。要使它们可用,你需要

require 'rake/ext/string'

命名空间

当你构建一个更大的构建脚本时,很容易最终得到许多具有相似名称的任务。Rake 有一个命名空间的概念,它可以帮助你组织这些任务。你可以使用以下方法创建命名空间

    namespace :articles do
      # put tasks inside the namespace here eg
      task :foo
    end
  

然后,你可以使用 rake articles:foo 调用命名空间任务

如果你需要引用当前命名空间之外的任务,那么你需要使用任务的完全限定名称——这通常使用任务名称的字符串形式更容易。

    namespace :other do
       task :special => 'articles:foo'
    end
  

内置清理

构建中一个常见的需求是清理你生成的的文件。Rake 提供了一种内置方法来实现这一点。Rake 有两个级别的清理:clean 和 clobber。Clean 是最温和的方法,它会删除所有中间文件,它不会删除最终产品,只会删除用于生成最终产品的临时文件。Clobber 使用更强的肥皂,会删除所有生成的文件,包括最终产品。本质上,clobber 会将你恢复到仅包含已签入源代码管理的文件的状态。

这里存在一些术语上的混淆。我经常听到人们使用“clean”来表示删除所有生成的文件,相当于 rake 的 clobber。因此,请注意这种混淆。

要使用内置清理,你需要使用 require 'rake/clean' 导入 rake 的内置清理。这会引入两个任务:clean 和 clobber。但是,就其本身而言,这些任务不知道要清理哪些文件。要告诉它,你需要使用一对文件列表:CLEAN 和 CLOBBER。然后,你可以使用类似 CLEAN.include('*.o') 的表达式将项目添加到文件列表中。请记住,clean 任务会删除 clean 列表中的所有内容,clobber 会删除 clean 和 clobber 列表中的所有内容。

零碎

默认情况下,如果在 rake 调用的代码中出现错误,rake 不会打印出堆栈跟踪。你可以使用 --trace 标志运行来获取堆栈跟踪,但我通常更愿意始终看到它。你可以通过将 Rake.application.options.trace = true 放入 rakefile 中来实现这一点。

同样,我发现 FileUtils 的文件操作输出很烦人。你可以使用 -q 选项在命令行中关闭它们,也可以在你的 rakefile 中通过调用 verbose(false) 来禁用它们。

在运行 rake 时,打开警告通常很有用,你可以通过操作 $VERBOSE 来实现,Mislav Marohnić 的文章中有一些关于使用 $VERBOSE很好的说明

要在 rakefile 本身中查找 rake 任务对象,请使用 Rake::Task[:aTask]。任务名称可以使用符号或字符串指定。这允许你使用 Rake::Task[:aTask].invoke 从另一个任务中调用一个任务,而无需使用依赖关系。你不需要经常这样做,但偶尔会很方便。

最后的想法

到目前为止,我发现 rake 是一种功能强大且易于使用的构建语言。当然,我熟悉 ruby 有帮助,但 rake 使我相信构建系统作为内部 DSL 比完整语言更有意义。脚本在许多方面都是构建东西的自然选择,而 rake 增加了足够的功能,在精良的语言之上提供了一个非常好的构建系统。我们还有优势,即 ruby 是一种开源语言,可以在我需要的平台上运行。

灵活的依赖关系规范带来的后果让我感到惊讶。它允许我做一些减少重复的事情,我认为这将使我将来更容易维护我的构建脚本。我发现了一些常见的函数,我将它们提取到一个单独的文件中,并在 martinfowler.com 和 refactoring.com 的构建脚本之间共享。

如果你正在自动化构建,你应该看看 rake。请记住,你可以将它用于任何环境,而不仅仅是 ruby。


进一步阅读

你可以从 rubyforge 获取 rake。关于 rakefile 的描述 是最好的文档来源之一。Jim Weirich 的博客也包含一些关于解释 rake 的条目,尽管你需要反向阅读它们(最早发布的条目排在最前面)。这些条目更详细地介绍了我在这里略过的内容(例如规则)。

使用脚本,你可以在 bash 中为 rake 设置 命令行补全

Joe White 有一个关于 Rake 库 中功能的页面。

如果你喜欢构建工具的内部 DSL 的想法,但更喜欢 Python,你可能想看看 SCons

致谢

感谢 Jason Yip、Juilian Simpson、Jon Tirsen 和 Jim Weirich 对本文草稿的评论。感谢 Dave Smith、Ori Peleg 和 Dave Stanton 在文章发表后的一些修正。

但最大的感谢要归功于 Jim Weirich,因为他首先编写了 rake。我的网站感谢你。

重大修订

2014 年 12 月 29 日: 更新了关于运行测试的讨论

2005 年 8 月 10 日: 首次发布