构建一个 Ruby Gem 第十一章 配置模式

世界并非非黑即白(无论我们多么希望它是那样的)。因为我们的 gem 的功能可能能为我们工作,并不意味着它能为所有人工作。
幸运的是,我们可以给用户加入自定义配置数据的能力,允许他们适配我们的代码为他们所用。在本章中,我们将会适配 mega_lotto gem 来获取一个配置块来修改 #draw 方法返回的数字的数量。

用例

我们的 mega_lotto gem 提供了功能来随机画出6个数字。然而,让我们假设某人对我们的 gem 的代码感兴趣,但是需要代码去产生 10 个随机数字在相同的范围内。

他们的一个选择是 fork 代码并且修改 gem 来适应他们的需求。然而,现在有一个随机 fork 的 gem 有着同样的名字并且它是不清晰的为什么一个应该被使用而不是另一个,特别是如果没有 README 说明了这些变更。

不必像上面说的那样做,我们可以使得我们的 mega_lotto gem 更加灵活通过默认返回 6 个数字,但是可以提供一个接口来自定义这个返回数量的值。

我们的目标是适配我们的 gem 来获取配置块就像这样:

MegaLotto.configure do |config| 
  config.drawing_count = 10
end

实现

让我们首先写一些功能需求的规范。因为 .configure 方法上面的是在 MegaLotto 的主命名空间中的,我们将会创建 spec 文件 spec/mega_lotto_spec.rb 。 要使用这个 spec, 我们将会断言在运行配置块后, #drawing 方法返回一个数组(和以前一样),但是这次里面会有10个数字。

require "spec_helper"

describe MegaLotto do 

  describe "#configure" do
  
    before do
      MegaLotto.configure do |config|
        config.drawing_count = 10 
      end
    end
    
    it "returns an array with 10 elements" do
      draw = MegaLotto::Drawing.new.draw
      expect(draw).to be_a(Array)
      expect(draw.size).to eq(10) 
    end
    
  end 
  
end

这个 spec 提供了一个高层的集成规范因为它是我们的 gem 的一个可得到的公用 API。正因如此,我们可以确保一旦这个 spec 通过了,我们的实现就完成了。和意料中的一样,当我们运行这个 spec,它失败了:

现在我们有一个 spec 来工作了。让我们继续我们的实现。

上面的失败解释说没有 MegaLotto.configure 方法, 所以让我们加上它:

module MegaLotto
  def self.configure 
  end
end

重新运行我们的 spec 这次给了我们一个不同的失败消息:

输出表明代码还是返回6个数字,我们预期不同因为我们的 .configure 方法还没被实现呢。

因为我们在使用一门面向对象的语言比如 Ruby,我们可以创建一个 Configuration 类,它的职责是...(咚咚咚)... 配置!

让我们用一些规范来开始吧。

# spec/mega_lotto/configuration_spec.rb

require "spec_helper"

module MegaLotto
  describe Configuration do
    describe "#drawing_count" do 
      it "default value is 6" do
        Configuration.new.drawing_count = 6 
      end
    end

    describe "#drawing_count=" do 
      it "can set value" do
        config = Configuration.new config.drawing_count = 7
        expect(config.drawing_count).to eq(7)
      end
    end
  end 
end

运行这个配置的 specs 产生了如下结果:

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

让我们添加 Configuration

# lib/mega_lotto/configuration.rb

module MegaLotto 
  class Configuration 
  end
end

让我们再试试:

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

什么??? 一样的消息…… 即使我们加入了 Configuration 类,我们的 gem 也不会自动加载它。所以我们可以深入入口文件 lib/mega_lotto.rb 并且加入 require 声明:

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

begin
  require "pry" rescue LoadError 
end
module MegaLotto
  def self.configure 
  end
end

现在有了 Configuration 类正确的加载,让我们再一次运行我们的 specs�:

MegaLotto::Configuration

  #drawing_count

default value is 6 (FAILED - 1)

  #drawing_count=

can set value (FAILED - 2) Failures:

- 1) &nbsp;&nbsp;MegaLotto::Configuration#drawing_count default value is 6 Failure/Error: expect(config.drawing_count).to eq(6) NoMethodError:
undefined method `drawing_count` for #<MegaLotto::Configuration> # ./spec/mega_lotto/configuration_spec.rb:8

- 2) &nbsp;&nbsp;MegaLotto::Configuration#drawing_count= can set value Failure/Error: config.drawing_count = 7 NoMethodError:

- undefined method `drawing_count=` for #<MegaLotto::Configuration> # ./spec/mega_lotto/configuration_spec.rb:15

Finished in 0.00175 seconds
2 examples, 2 failures

即使我们依然失败了,但是我们有进步了。上面的失败是关于的是缺少一个 #draing_count= 方法,所以让我们为了它加入一个 accessor:

module MegaLotto 
  class Configuration
    attr_accessor :drawing_count 
  end
end

注意:我们可以仅仅加入一个 attr_writer 来满足这个需求。然而,我知道我将来还是需要一个 getter 的,所以我选择一次性一起加上。

我们的 accessor 到位了,让我们再检查一下 specs:

MegaLotto::Configuration
  #drawing_count=
    can set value
  #drawing_count

default value is 6 (FAILED - 1) Failures:

1) MegaLotto::Configuration#drawing_count default value is 6 Failure/Error: expect(config.drawing_count).to eq(6)

      expected: 6
            got: nil

(compared using ==)
# ./spec/mega_lotto/configuration_spec.rb:8

Finished in 0.00239 seconds
2 examples, 1 failure

仍然是一个失败,但是我们已经在缓慢的进步了。默认值没有被设置,所以我们要改变实现:

module MegaLotto 
  class Configuration
    attr_accessor :drawing_count

    def initialize 
      @drawing_count = 6
    end 
  end
end

再次运行 specs 来看看 Configuration 类是否正常:

MegaLotto::Configuration

  #drawing_count

    default value is 6

  #drawing_count=

can set value

Finished in 0.00172 seconds
2 examples, 0 failures

再次运行 spec/mega_lotto.rb 主类:

MegaLotto
#configure
returns an array with 10 elements (FAILED - 1) Failures:
1) MegaLotto#configure returns an array with 10 elements Failure/Error: expect(draw.size).to eq(10)

      expected: 10
            got: 6

(compared using ==)
# ./spec/mega_lotto_spec.rb:15:in `block (3 levels) in <top (required)>'

Finished in 0.00168 seconds
1 example, 1 failure

我们仍然有同样的失败,但这是因为我们没有改变 MegaLotto::Drawing 来确保使用新的配置类。因为我们有这个碉堡的新类,让我们使用 MegaLotto::Drawing:

module MegaLotto
  class Drawing
    def draw
      MegaLotto.configuration.drawing_count.times.map { single_draw }
    end

    private

    def single_draw rand(0...60)

    end 
  end
end

运行 draing 类的 specs 给了我们如下的输出:

MegaLotto::Drawing
#draw

each element is less than 60 (FAILED - 1) each element is an integer (FAILED - 2) returns an array (FAILED - 3)
using the default drawing count

returns an array with 6 elements (FAILED - 4) Failures:

- 1) &nbsp;&nbsp;MegaLotto::Drawing#draw each element is less than 60 Failure/Error: let(:draw) { MegaLotto::Drawing.new.draw } NoMethodError:

- undefined method `configuration` for MegaLotto:Module # ./lib/mega_lotto/drawing.rb:4:in `draw'
- # ./spec/mega_lotto/drawing_spec.rb:6
# ./spec/mega_lotto/drawing_spec.rb:19

- 2) &nbsp;&nbsp;MegaLotto::Drawing#draw each element is an integer Failure/Error: let(:draw) { MegaLotto::Drawing.new.draw } NoMethodError:

- undefined method `configuration` for MegaLotto:Module # ./lib/mega_lotto/drawing.rb:4:in `draw'
- # ./spec/mega_lotto/drawing_spec.rb:6
# ./spec/mega_lotto/drawing_spec.rb:13

- 3) &nbsp;&nbsp;MegaLotto::Drawing#draw returns an array
Failure/Error: let(:draw) { MegaLotto::Drawing.new.draw } NoMethodError:

- undefined method `configuration` for MegaLotto:Module # ./lib/mega_lotto/drawing.rb:4:in `draw'
- # ./spec/mega_lotto/drawing_spec.rb:6
# ./spec/mega_lotto/drawing_spec.rb:9

- 4) &nbsp;&nbsp;MegaLotto::Drawing#draw using the default drawing count returns an array with 6 elements
Failure/Error: let(:draw) { MegaLotto::Drawing.new.draw } NoMethodError:

- undefined method `configuration` for MegaLotto:Module # ./lib/mega_lotto/drawing.rb:4:in `draw'
- # ./spec/mega_lotto/drawing_spec.rb:6
# ./spec/mega_lotto/drawing_spec.rb:26

好吧…… 我猜想这很清楚了,是没有一个配置的 accessor, 对吧?让我们把它加入到 lib/mega_lotto.rb 中:

module MegaLotto 
  class << self
    attr_accessor :configuration 
  end
  def self.configure    
  end 
end

以及我的 specs:


MegaLotto::Drawing

#draw

each element is less than 60 (FAILED - 1) each element is an integer (FAILED - 2) returns an array (FAILED - 3)
using the default drawing count

returns an array with 6 elements (FAILED - 4) Failures:

- 1) &nbsp;&nbsp;MegaLotto::Drawing#draw each element is less than 60 Failure/Error: let(:draw) { MegaLotto::Drawing.new.draw } NoMethodError:

- undefined method `drawing_count` for nil:NilClass # ./lib/mega_lotto/drawing.rb:4:in `draw'
- # ./spec/mega_lotto/drawing_spec.rb:6
# ./spec/mega_lotto/drawing_spec.rb:19

- 2) &nbsp;&nbsp;MegaLotto::Drawing#draw each element is an integer Failure/Error: let(:draw) { MegaLotto::Drawing.new.draw } NoMethodError:

- undefined method `drawing_count` for nil:NilClass # ./lib/mega_lotto/drawing.rb:4:in `draw'
- # ./spec/mega_lotto/drawing_spec.rb:6
# ./spec/mega_lotto/drawing_spec.rb:13

- 3) &nbsp;&nbsp;MegaLotto::Drawing#draw returns an array
Failure/Error: let(:draw) { MegaLotto::Drawing.new.draw } NoMethodError:

- undefined method `drawing_count` for nil:NilClass # ./lib/mega_lotto/drawing.rb:4:in `draw'
- # ./spec/mega_lotto/drawing_spec.rb:6
# ./spec/mega_lotto/drawing_spec.rb:9

- 4) &nbsp;&nbsp;MegaLotto::Drawing#draw using the default drawing count returns an array with 6 elements
Failure/Error: let(:draw) { MegaLotto::Drawing.new.draw } NoMethodError:

- undefined method `drawing_count` for nil:NilClass # ./lib/mega_lotto/drawing.rb:4:in `draw'
- # ./spec/mega_lotto/drawing_spec.rb:6
# ./spec/mega_lotto/drawing_spec.rb:26



Finished in 0.00146 seconds
4 examples, 4 failures

这次是一个不同的消息, 关于 configuration 的 accessor 没有 #drawing_count 方法。
这很重要因为我们事实上没有从 #configuration 中返回任何东西。让我们实例一个新的 Configuration 对象并且看看它给了我们什么:

module MegaLotto 
  class << self
    attr_writer :configuration 
  end

  def self.configuration 
    Configuration.new
  end

  def self.configure
  end
end

现在,Drawing 类的 specs 通过了:

MegaLotto::Drawing

#draw

    each element is an integer
    each element is less than 60
    returns an array
    using the default drawing count





      returns an array with 6 elements





Finished in 0.01007 seconds
4 examples, 0 failures

让我们翻回去看看 spec/mega_lotto_spec.rb 来确定我们的位置:

MegaLotto

#configure

returns an array with 10 elements (FAILED - 1) Failures:

1) MegaLotto#configure returns an array with 10 elements Failure/Error: expect(draw.size).to eq(10)

      expected: 10
            got: 6





(compared using ==)
# ./spec/mega_lotto_spec.rb:15

Finished in 0.00167 seconds
1 example, 1 failure

还是失败,但是至少我们已经看到了设置一个全局的配置文件大概是什么样子了。.configure 方法需要 yield 配置的代码块对于一个 Configuration 类的新的实例。然而,我们将会需要记住配置的实例,所以当 Drawing 类调用 #draing_count 时,它返回了初始化的配置值。

module MegaLotto 
  class << self
    attr_writer :configuration 
  end
  def self.configuration
    @configuration ||= Configuration.new
  end
  def self.configure
    yield(configuration)
  end 
end

再次运行我们的 specs, 我们看到这次我们的测试通过了:

MegaLotto

#configure

    returns an array with 10 elements





Finished in 0.00168 seconds
1 example, 0 failures

为了清醒的缘故,让我们运行整个测试套件来确定每种情况都被覆盖到了:

$ rake
.......

Finished in 0.00688 seconds
7 examples, 0 failures

……我们棒极了!除了,如果我们运行我们的整个测试套件在同一行几次,我们最终会看到一个失败:

Failures:

1) MegaLotto::Drawing#draw returns an Array with 6 elements Failure/Error: expect(drawing.size).to eq(6)

      expected: 6
            got: 10





(compared using ==)
# ./spec/mega_lotto/drawing_spec.rb:13

Finished in 0.00893 seconds
7 examples, 1 failure

怎么回事???在我们的 MegaLotto.configure 设置中,我们添加了下面这个代码块:

before :each do 
  MegaLotto.configure do |config|
    config.drawing_count = 10 
  end
end

因为配置是全局的,如果这个 spec 在我们的测试套件的别的测试之前,剩下的 specs 会使用它。所以当 MegaLotto::Drawing 的 specs 运行时,就会返回 10 个元素而不是 6 个(默认的数量),然后我们就会看到失败了。

For global state like this, it’s best to clean up after each spec to ensure the system is returned back to the default state. In our case, we can implement a .reset method on
对于想这样的全局状态,最好是在每个 spec 运行之后清理一下来确保系统已经返回到了默认的状态。对于我们目前的情况,我们可以实现一个 .reset 方法在 MegaLotto 并且设置配置返回一个新的 Configuration 类实例。让我们从 spec/mega_lotto_spec.rb 开始把:

describe ".reset" do 
  before :each do
    MegaLotto.configure do |config|
      config.drawing_count = 10
    end
  end

  it "resets the configuration" do 
    MegaLotto.reset
    config = MegaLotto.configuration
    expect(config.drawing_count).to eq(6) 
  end
end

正如期待的那样,我们看到了失败因为我们还没有实现 .reset 方法:

Failures:

1) MegaLotto.reset resets the configuration Failure/Error: MegaLotto.reset NoMethodError:

undefined method `reset` for MegaLotto:Module # ./spec/mega_lotto_spec.rb:28

Finished in 0.00762 seconds
8 examples, 1 failure

让我们来实现它:

module MegaLotto 
  class << self
    attr_writer :configuration 
  end

  def self.configuration
    @configuration ||= Configuration.new
  end

  def self.reset
    @configuration = Configuration.new
  end

  def self.configure
    yield(configuration)
  end 
end

我们的 .reset 方法的 specs 通过了,所以现在我们需要确保它是否在我们的 .configure spec 中起到了清理的作用:

describe "#configure" do

  before :each do 
    MegaLotto.configure do |config|
      config.drawing_count = 10 
    end
  end
  it "returns an array with 10 elements" do
    draw = MegaLotto::Drawing.new.draw
    expect(draw).to be_a(Array)
    expect(draw.size).to eq(10) 
  end
  after :each do 
    MegaLotto.reset
  end
end

现在我们可以确保我们的 specs 通过了,无论执行的顺序如何。

本地配置

上面的配置方式是一个全局的配置对象。这种做法的坏处是我们不能有多个实例在我们的代码运行时有不通的配置。为了避免这种情况,我们可以独立配置类并且仅仅传给需要的对象。这样做,我们可以避免全局使用 MegaLotto.configure。

按照这个思路,我们的 MegaLotto::Drawing 类看起来就像这样:

module MegaLotto 
  class Drawing
    attr_accessor :config
    
    def initialize(config = Configuration.new)
      @config = config
    end

    def draw
      config.drawing_count.times.map { single_draw }
    end

    private
    def single_draw rand(0...60)
    end 
  end
end

我们可以提供我们自己的配置对象在实例化期间如果默认的不合适的话。这样,只要对象可以响应 drawing_count, 就没问题了。

require 'ostruct'
config = OpenStruct.new(drawing_count: 10)

MegaLotto::Drawing.new(config).draw #=> [23, 4, 21, 33, 48, 12, 43, 13, 2, 5]

两种方式都是有效的,所以我们让你自己来决定在你的 gem 中使用哪一种。

真实世界的例子

这个 CarrierWave gem 是一个很热门的支持上传头像的可选项。作者(们)认识到不是每个人都想要上传图片到本地系统,所以他们提供了功能来支持 Amazon S3 和其他类似的储存服务。为了设置这个值,你要使用一个几乎和我们上面写的一模一样的配置代码块。

Thoughtbot 写了一篇很好的文章关于他们的 Clearance gem 的配置实现。即使你不打算使用 Clearance,这篇文章也值得一读。

总结

保持 gem 的可配置是一个平衡你的用例和其他人的用例的一个结果。有一点需要注意的是如果提供了太多的配置可能会让你的 gem 的内部不必要的复杂化。正如你大概知道的,Ruby 是一个充满了配置的语言,并且它也提供合理的默认配置,只接收配置如果需求出现的话。

一种平衡复杂性的方法是创建一个系统,用户可以编写他们自己的中间件来修改默认系统的行为。
Mike Perham 为 Sidekq 创建了一个中间件系统,让用户可以如他们所愿的添加自己的功能。这样做就不用在出现唯一的用例时改变 gem。要实现这样的系统超出了本书的范围。然而,如果你想要学习更多,Sidekiq 的实现是一个很好的开始的地方。

下一章将会是第一个关于 Rails 的整合的章节。我们将会看到几个方法来整合我们的代码到 Rails 并且探索使用 Railties 的好处。

推荐阅读更多精彩内容