构建一个 Ruby Gem 第四章 代码

我们将会在本章实现我们的 mega_lotto 的核心代码. 然而, 在我们开始之前, 我想要花些时间来讨论一下 Ruby 的命名空间和从其他目录加载 class 时可能发生的情况.

让我们再次看看我们的 gem 的 API 风格:

MegaLotto::Drawing.new.draw # => [23, 22, 3, 7, 16]

命名空间

我不得不承认, 我建议从一个 Ruby gem 中学习命名空间是如何工作的. 因为我们给我们的gem命名为 mega_lotto, 相应的 Ruby 命名空间是 MegaLotto. 这是最佳实战, 来避免和其它类库的命名冲突. 假设名字 MegaLotto 是唯一的, 我们可以确定我们的代码不会和其它gem的代码冲突.

一般来说, 一个 Ruby 库的文件名对应一个相同根命名空间的模块/类的名字. 下面是几个例子:

lib/mega_lotto/store.rb => MegaLotto::Store
lib/mega_lotto/lucky_ticket.rb => MegaLotto::LuckyTicket

注意: 命名代码和模块是困难的. 我最好的建议是跟随单一职责原则并且创建只做一件事的文件/模块, 并且把一件事做好.

包含代码

记住, 默认情况下, bundler 为我们创建了 lib/mega_lotto.rb 文件.

require "mega_lotto/version"

module MegaLotto
  # Your code goes here...
end

其中, bundler 给我们创建了 MegaLotto 的命名空间并且建议我们的 gem 的代码应该写在这里面. 通常来说, 我们只会把配置和初始化代码放在这个文件里, 把具体实现放在 /lib/mega_lotto 目录中的其他类库中.

注意这行:

require "mega_lotto/version"

当我们使用 bundler 初始化我们的 Ruby gem 时, bundler 创建了文件 lib/mega_lotto/version.rb:

module MegaLotto
  VERSION = "0.0.1"
end

文件 lib/mega_lotto/version.rb 文件定义了一个常量 (VERSION) 来标志我们的 gem 的版本. 当我们发布新版时, 我们会增加 prior 的值来推送我们的新代码到 Rubygems. 另外, 注意我们如何在文件 lib/mega_lotto/ 目录中使用相对路径来包含加载文件的.

实现

作为 Rubyists, 测试我们工作流程中必备的一部分. 测试不仅能保证我们的系统是可用的, 也保证了我们能写出可维护的代码. 我们知道我们的API的输入和输出, 所以我们可以从一个 spec 开始( spec/mega_lotto/drawing_spec.rb ) 来驱动我们的 Drawing 类的实现:

require "spec_helper"
module MegaLotto
describe Drawing do
    describe "#draw" do
        let(:draw) { MegaLotto::Drawing.new.draw }
        it "returns an array" do
            expect(draw).to be_a(Array)
        end
        it "returns an array with 6 elements" do
            expect(draw.size).to eq(6)
        end
        it "each element is an integer" do
            draw.each do |drawing|
                expect(drawing).to be_a(Integer)
            end
        end
        it "each element is less than 60" do
            draw.each do |drawing|
                expect(drawing).to be < 60
            end
        end
    end
end

简单来说: 这个 spec 断言 #draw 方法返回了一个 5 个整数组成的数组, 每一个整数都小于 60.

如果我们运行这个 spec, 我们会得到预期的错误:

/Users/bhilkert/Dropbox/code/mega_lotto/spec/mega_lotto/drawing_spec.rb:4:
in `<module:MegaLotto>`: uninitialized constant MegaLotto::Drawing (NameError)

迈着最小的步子来修复我们的失败 spec, 让我们在 lib/mega_lotto/drawing.rb 中创建 MegaLotto::Drawing 类 :

module MegaLotto
  class Drawing
  end
end

注意: 注意到 Drawing 类是如何在 MegaLotto 命名空间内部的? 文件 lib/mega_lotto.rb 定义了 MegaLotto 的根命名空间, 所以其它在 lib/mega_lotto/ 目录下的类库就会在MegaLotto命名空间下.

即使我们创建一个新的类 MegaLotto::Drawing, 我们的 gem 也还不知道它. gem里的文件不会被自动加载. 为了加载我们的 Drawing 类, 我们需要从 lib/mega_lotto.rb 这个入口文件中 require 它:

require "mega_lotto/version"
require "mega_lotto/drawing"

begin
    require "pry"
rescue LoadError
end

module MegaLotto
end

现在, 通过在宿主程序中包含 lib/mega_lotto.rb 入口文件, 我们可以得到在 lib/mega_lotto/drawing.rb 里的功能了!

If we re-run the spec for our Drawing class, we get the following:

如果我们重新运行 Drawing 类的 spec, 我们会得到下面的内容:

MegaLotto::Drawing
#draw
returns an array (FAILED - 1)
each element is less than 60 (FAILED - 2)
returns an array with 5 elements (FAILED - 3)
each element is an integer (FAILED - 4)
Failures:
1) MegaLotto::Drawing#draw returns an array
Failure/Error: let(:draw) { MegaLotto::Drawing.new.draw }
NoMethodError:
undefined method `draw` for #<MegaLotto::Drawing:0x007fef4b0bf198>
# ./spec/mega_lotto/drawing_spec.rb:6
# ./spec/mega_lotto/drawing_spec.rb:9
2) MegaLotto::Drawing#draw each element is less than 60
Failure/Error: let(:draw) { MegaLotto::Drawing.new.draw }
NoMethodError:
undefined method `draw` for #<MegaLotto::Drawing:0x007fef4b0bc510>
# ./spec/mega_lotto/drawing_spec.rb:6
# ./spec/mega_lotto/drawing_spec.rb:23
3) MegaLotto::Drawing#draw returns an array with 5 elements
Failure/Error: let(:draw) { MegaLotto::Drawing.new.draw }
NoMethodError:
undefined method `draw` for #<MegaLotto::Drawing:0x007fef4b0b5fd0>
# ./spec/mega_lotto/drawing_spec.rb:6
# ./spec/mega_lotto/drawing_spec.rb:13
4) MegaLotto::Drawing#draw each element is an integer
Failure/Error: let(:draw) { MegaLotto::Drawing.new.draw }
NoMethodError:
undefined method `draw` for #<MegaLotto::Drawing:0x007fef4b0b4270>
# ./spec/mega_lotto/drawing_spec.rb:6
# ./spec/mega_lotto/drawing_spec.rb:17
Finished in 0.00218 seconds
4 examples, 4 failures

每个失败都是由于缺少一个 #drawing 方法, 所以让我们加上它:

module MegaLotto
    class Drawing
        def draw
        end
    end
end

我们的 specs 显示的信息不同了, 现在的失败原因是缺少返回的数组:

MegaLotto::Drawing
#draw
returns an array (FAILED - 1)
each element is an integer (FAILED - 2)
returns an array with 5 elements (FAILED - 3)
each element is less than 60 (FAILED - 4)
Failures:
1) MegaLotto::Drawing#draw returns an array
Failure/Error: expect(draw).to be_a(Array)
expected nil to be a kind of Array
# ./spec/mega_lotto/drawing_spec.rb:9
2) MegaLotto::Drawing#draw each element is an integer
Failure/Error: draw.each do |drawing|
NoMethodError:
undefined method `each` for nil:NilClass
# ./spec/mega_lotto/drawing_spec.rb:17
3) MegaLotto::Drawing#draw returns an array with 5 elements
Failure/Error: expect(draw.size).to eq(5)
NoMethodError:
undefined method `size` for nil:NilClass
# ./spec/mega_lotto/drawing_spec.rb:13
4) MegaLotto::Drawing#draw each element is less than 60
Failure/Error: draw.each do |drawing|
NoMethodError:
undefined method `each` for nil:NilClass
# ./spec/mega_lotto/drawing_spec.rb:23
Finished in 0.00181 seconds
4 examples, 4 failures

从第一个失败开始, 我们来给#draw方法返回一个空的数组, 再看看这会带来什么结果:

module MegaLotto
    class Drawing
        def draw
            []
        end
    end
end

我们的 specs 的结果:

MegaLotto::Drawing
#draw
returns an array
returns an array with 5 elements (FAILED - 1)
each element is less than 60
each element is an integer
Failures:
1) MegaLotto::Drawing#draw returns an array with 5 elements
Failure/Error: expect(draw.size).to eq(5)
expected: 5
got: 0
(compared using ==)
# ./spec/mega_lotto/drawing_spec.rb:13:
in `block (3 levels) in <module:MegaLotto>`
Finished in 0.00773 seconds
4 examples, 1 failure

已知我们通过返回一个空的数组加入了一个无条理的实现, 让我们通过返回一个5个元素的数组满足最后一个失败:

module MegaLotto
    class Drawing
        def draw
            Array.new(5)
        end
    end
end

我们的 spec 失败现在产生了特定的关于数组的元素:

MegaLotto::Drawing
#draw
each element is less than 60 (FAILED - 1)
each element is an integer (FAILED - 2)
returns an array
returns an array with 5 elements
Failures:
1) MegaLotto::Drawing#draw each element is less than 60
Failure/Error: expect(drawing).to be < 60
NoMethodError:
undefined method `<` for nil:NilClass
# ./spec/mega_lotto/drawing_spec.rb:24
# ./spec/mega_lotto/drawing_spec.rb:23:in `each`
# ./spec/mega_lotto/drawing_spec.rb:23
2) MegaLotto::Drawing#draw each element is an integer
Failure/Error: expect(drawing).to be_a(Integer)
expected nil to be a kind of Integer
# ./spec/mega_lotto/drawing_spec.rb:18
# ./spec/mega_lotto/drawing_spec.rb:17:in `each`
# ./spec/mega_lotto/drawing_spec.rb:17
Finished in 0.00417 seconds
4 examples, 2 failures

现在让我们返回0到60之间的数组而不是nil:

module MegaLotto
    class Drawing
        def draw
            5.times.map { single_draw }
        end
        private
        def single_draw
            rand(0...60)
        end
    end
end

现在看看我们的 specs, 可以看到我们满足了要求:

MegaLotto::Drawing
#draw
each element is less than 60
returns an array
returns an array with 5 elements
each element is an integer
Finished in 0.00551 seconds
4 examples, 0 failures

万岁! 让我们提交我们的变更并且庆祝我们的新 gem。 我们离发布更近了一步。

总结

驱动测试开发是一个很多 Rubyist 遵照的实践。如果没有测试的设置,先写下你的测试通常会产出高质量可维护的代码。我给了你我上面的工作流的感觉,但是不是所有的开发者都是一样的。实验你的工作流并且确定什么样的适合你。虽然测试在开始花费额外的时间,但这样的投资节省了让我的头疼的麻烦。我鼓励你尝试它如果你没实践过TDD。

在下一章, 我们将会看看 bundler 提供的 rake 任务是如何来帮助发布的。