简介

Ruby is Big in China Ruby China

关于作者

这篇文章翻译自 bbatsov 所写的 Ruby Style Guide 及 Rails Style Guide。

关于译者

Juanito Fatas,你可以在 twitter、ruby-china 找到我。

不好意思,我刚学习 Ruby 及 Rails,翻译的质量可能不太好,

有错误不好的地方,麻烦去 github 开任务给我。

提交建议

Ruby Style Guide 建議

Rails Style Guide 建議

为什么翻译此文

几个礼拜前我开始学习 Ruby 及 Rails。我刚好看到了这两个指导,我觉得这可以让我有全观的理解及避免可能犯的错误,这样子再去学习会更有方向,这是我翻译的原因。

而关于风格,我看过一本书,有一段文字,诠释的很好:

Style is necessary only where understanding is missing. A corollary to this is that sometimes the only way to effectively use something you don’t understand is to copy styles observed elsewhere. – From Let Over Lambda

我希望你读的愉快。

内容许可

内容采用创用 CC 2.5 授权释出

Creative Commons License

Ruby 风格指导

序幕

作为 Ruby 开发者,有一件总是令我烦心的事 — Python 开发者有一份好的编程风格参考指南(PEP-8) 而我们永远没有一份官方指南,一份记录 Ruby 编程风格及最佳实践的指南。而我们确信风格很重要。我也相信这些好家伙们,像我们是 Ruby 开发者,应该可以自己产生一份这个梦寐以求的文档。

这份指南开始是作为我们公司内部 Ruby 编程指南(由我所写的)。进行到某个部分时,我决定要把我的成果贡献给广大的 Ruby 社群,而且这个世界需要从另一个公司内部的一点帮助。然而这个世界也可以从由社群制定及策动的一系列 Ruby 编程惯例、实践及风格中受益。

在开始写这份指南时,我收到世界上很多优秀 Ruby 社群用户们的反馈。感谢所有的建议及帮助!我们同心协力创造一个能够让每一个 Ruby 开发者受益的资源。

顺道一提,如果你对 Rails 感兴趣,你可以看看这份互补的 Ruby on Rails 3 风格指南。

Ruby 风格指南

这份 Ruby 风格指南向你推荐现实世界中,Ruby 程序员如何写出可被别的Ruby程序员维护的代码。一份风格指南,反映出现实世界中的用法,带有一个理想,帮助人们避免使用危险的代码 — 不管它看起来有对好。

本指南分成数个相关规则的小节。我试着在每个规则后说明理由(如果省略的话,我相信理由是显而易见的)。

我没有想到所有的规则 — 他们大致上是基于,我作为一个专业软体工程师的广泛生涯,从 Ruby 社群成员所得到的反馈及建议,和数个高度评价的 Ruby 程式设计资源,像是 "Programming Ruby 1.9" 以及 "The Ruby Programming Language"。

本指南仍在完善中 — 某些规则缺乏实例,某些规则没有例子来清楚地演示它们。在最后交付时,这些议题将会被解决 — 就先把它们记在心理吧。

你可以使用 Transmuter 来产生本指南的一份 PDF 或 HTML 复本。

源代码排版

使用 UTF-8 作为源文件的编码。

每个缩排层级使用两个空格。

# 好
def some_method
  do_something
end

# 差 - 四个空格
def some_method
    do_something
end

使用 Unix 风格的行编码(缺省涵盖 BSD/Solaris/Linux/OSX 的用户,Windows 用户要格外小心。) 如果你使用 Git ,你也许会想加入下面这个配置,来保护你的项目被 Windows 的行编码侵入:

$ git config --global core.autocrlf true

使用空格来围绕操作符,逗号 , 、冒号 : 及分号 ; 之后,围绕在 { 和 } 之前。空格可能对(大部分)Ruby 直译器来说是无关紧要的,但正确的使用是写出可读性高的代码的关键。

sum = 1 + 2
a, b = 1, 2
1 > 2 ? true : false; puts 'Hi'
[1, 2, 3].each { |e| puts e }

唯一的例外是当使用指数操作符时:

# 差
e = M * c ** 2

# 好
e = M * c**2

不要有空格在 ( 、 [ 之后,或 ] 、 ) 之前。

some(arg).other
[1, 2, 3].length

把 when 跟 case 缩排在同一层。我知道很多人不同意这一点,但这是“The Ruby Programming Language”及“Programming Ruby”所使用的风格。

case
when song.name == 'Misty'
  puts 'Not again!'
when song.duration > 120
  puts 'Too long!'
when Time.now.hour > 21
  puts "It's too late"
else
  song.play
end

kind = case year
       when 1850..1889 then 'Blues'
       when 1890..1909 then 'Ragtime'
       when 1910..1929 then 'New Orleans Jazz'
       when 1930..1939 then 'Swing'
       when 1940..1950 then 'Bebop'
       else 'Jazz'
       end

在 def 之间使用空行,并且把方法分成合乎逻辑的段落。

def some_method
  data = initialize(options)

  data.manipulate!

  data.result
end

def some_method
  result
end

当一个方法呼叫的参数扩展至多行时,排列它们。

# 一开始(一行太长)
def send_mail(source)
  Mailer.deliver(to: 'bob@example.com', from: 'us@example.com', subject: 'Important message', body: source.text)
end

# 差(一般的缩排)
def send_mail(source)
  Mailer.deliver(
    to: 'bob@example.com',
    from: 'us@example.com',
    subject: 'Important message',
    body: source.text)
end

# 差(两倍缩排)
def send_mail(source)
  Mailer.deliver(
      to: 'bob@example.com',
      from: 'us@example.com',
      subject: 'Important message',
      body: source.text)
end

# 好
def send_mail(source)
  Mailer.deliver(to: 'bob@example.com',
                 from: 'us@example.com',
                 subject: 'Important message',
                 body: source.text)
end

使用 RDoc 以及它的惯例来撰写 API 文档。不要在注解区块及 def 之前放一个空行。 让每一行保持少于 80 个字符。 避免尾随的空白。

语法

使用 def 时,当有参数时使用括号。当方法不接受任何参数时,省略括号。

def some_method
# body omitted
end

def some_method_with_arguments(arg1, arg2)
# body omitted
end

永远不要使用 for ,除非你很清楚为什么。大部分情况应该使用迭代器来取代。 for 是由 each 所实作的(所以你加入了一层的迂回),但出乎意料的是 — for 并没有包含一个新的视野(不像是 each )而在这个区块中定义的变量将会被外部所看到。

arr = [1, 2, 3]

# 差
for elem in arr do
  puts elem
end

# 好
arr.each { |elem| puts elem }

永远不要在多行的 if/unless 使用 then

# 差
if some_condition then
  # body omitted
end

# 好
if some_condition
  # body omitted
end

偏爱三元操作符 ? : 胜于 if/then/else/end 结构。它更为常见及更精准。

# 差
result = if some_condition then something else something_else end

# 好
result = some_condition ? something : something_else

使用一个表达式给一个三元操作符的分支。这也意味着三元操作符不要嵌套。嵌套情况使用 if/else 结构。

# 差
some_condition ? (nested_condition ? nested_something : nested_something_else) : something_else

# 好
if some_condition
  nested_condition ? nested_something : nested_something_else
else
  something_else
end

永远不要使用 if x: ... — 它已经在 Ruby 1.9 被移除了。使用三元操作符来取代。

# 差
result = if some_condition: something else something_else end

# 好
result = some_condition ? something : something_else

永远不要使用 if x: ... 使用三元操作符来取代。

一行的情况使用 when x then ...。替代方案的语法 when x: ... 在 Ruby 1.9 被移除了。

永远不要使用 when x: ...。参考前一个规则。

布尔表达式使用 &&/||,控制流程使用 and/or。 (经验法则:如果你需要使用外部括号,你正在使用错误的操作符。)

# 布尔表达式
if some_condition && some_other_condition
  do_something
end

# 控制流程
document.saved? or document.save!

避免多行的 ? :(三元操作符),使用 if/unless 来取代。

偏爱 if/unless 修饰符当你有单行的主体。

另一个好的方法是使用控制流程的 and/or。

# 差
if some_condition
  do_something
end

# 好
do_something if some_condition

# 另一个好方法
some_condition and do_something

否定条件偏爱 unless 优于 if(或是控制流程 or)。

# 差
do_something if !some_condition

# 好
do_something unless some_condition

# 另一个好方法
some_condition or do_something

永远不要使用 unless 搭配 else 。将它们改写成肯定条件。

# 差
unless success?
  puts 'failure'
else
  puts 'success'
end

# 好
if success?
  puts 'success'
else
  puts 'failure'
end

不要使用括号围绕 if/unless/while 的条件式,除非这条件包含了一个赋值(见下面使用 = (一个赋值)的返回值)。

# 差
if (x > 10)
  # body omitted
end

# 好
if x > 10
  # body omitted
end

# 好
if (x = self.next_value)
  # body omitted
end

忽略围绕方法参数的括号,如内部 DSL (如:Rake, Rails, RSpec),Ruby 中带有“关键字”状态的方法(如:attr_reader, puts)以及属性存取方法。

所有其他的方法呼叫使用括号围绕参数。

class Person
  attr_reader :name, :age

# 忽略
end

temperance = Person.new('Temperance', 30)
temperance.name

puts temperance.age

x = Math.sin(y)
array.delete(e)

单行区块喜好 {...} 胜于 do..end。多行区块避免使用 {...}(多行串连总是​​丑陋)。在 do...end 、“控制流程”及“方法定义” ,永远使用 do...end (如 Rakefile 及某些 DSL)。串连时避免使用 do...end。

names = ["Bozhidar", "Steve", "Sarah"]

# 好
names.each { |name| puts name }

# 差
names.each do |name|
  puts name
end

# 好
names.select { |name| name.start_with?("S") }.map { |name| name.upcase }

# 差
names.select do |name|
  name.start_with?("S")
end.map { |name| name.upcase }

某些人会争论多行串连时,使用 {...} 看起来还可以,
但他们应该问问自己 — 这样代码真的可读吗
以及不能把区块内容取出来放到绝妙的方法中吗。

避免在不需要的场合时使用 return 。

# 差
def some_method(some_arr)
  return some_arr.size
end

# 好
def some_method(some_arr)
  some_arr.size
end

当赋予缺省值给方法参数时,使用空格围绕 = 操作符。

# 差
def some_method(arg1=:default, arg2=nil, arg3=[])
  # 做些什么...
end

# 好
def some_method(arg1 = :default, arg2 = nil, arg3 = [])
  # 做些什么...
end


然而几本 Ruby 书建议第一个风格,第二个风格在实践中更为常见
(并可争议地可读性更高一点)。

避免在不需要的场合使用续行 \ 。在实践中,尽量避免使用续行。

# 差
result = 1 - \
         2

# 好 (但仍丑的跟地狱一样)
result = 1 \
         - 2

使用 =(一个赋值)的返回值是好的,但用括号环绕赋值。

# 好 — 演示赋值的目标用途
if (v = array.grep(/foo/)) ...

# 差
if v = array.grep(/foo/) ...

# 也很好 — 演示赋值的目标用途及有正确的优先序
if (v = self.next_value) == "hello" ...

随意使用 ||= 来初始化变量

# 仅在 name 为 nil 或 false 时,把名字设为 Bozhidar。
name ||= 'Bozhidar'

不要使用 ||= 来初始化布尔变量。

(想看看如果现在的值刚好是 false 时会发生什么。)

# 差 — 会把 enabled 设成真,即便它本来是假。
enabled ||= true

# 好
enabled = true if enabled.nil?

避免使用 Perl 风格的特别变量(像是 $0-9, $`, 等等)。它们看起来非常神秘以及不鼓励使用一行的脚本。

避免在方法名与左括号之间放一个空格。

# 差
f (3 + 2) + 1

# 好
f(3 + 2) + 1

如果方法的第一个参数由左括号开始,永远在这个方法呼叫里使用括号。

举个例子,写 f((3+2) + 1)。

总是使用 -w 来执行 Ruby 直译器,如果你忘了某个上述的规则,它就会警告你!

当你的哈希键是符号时,使用 Ruby 1.9 哈希字面语法。

# 差
hash = { :one => 1, :two => 2 }

# 好
hash = { one: 1, two: 2 }

使用新的 lambda 字面语法。

# 差
lambda = lambda { |a, b​​| a + b }
lambda.call(1, 2)

# 好
lambda = ->(a, b) { a + b }
lambda.(1, 2)

未使用的区块参数使用 _ 。

# 差
result = hash.map { |k, v| v + 1 }

# 好
result = hash.map { |_, v| v + 1 }

命名

方法与变量使用蛇底式小写( snake_case )。

类别与模组使用驼峰式大小写( CamelCase )。(保留像是HTTP、RFC、XML 这种缩写为大写)

其他常数使用尖叫蛇底式大写( SCREAMING_SNAKE_CASE )。

判断式方法的名字(返回布尔值的方法)应以问号结尾。 (即 Array#empty? )

有潜在“危险性”的方法,若此危险方法有安全版本存在时,应以惊叹号结尾(即:改动 self 或参数、 exit! 等等方法)。

# 不好 - 没有对应的安全方法
class Person
  def update!
  end
end

# 好
class Person
  def update
  end
end

# 好
class Person
  def update!
  end

  def update
  end
end

如果可能的话,从危险方法(bang)的角度来定义对应的安全方法(non-bang)。

class Array
  def flatten_once!
    res = []

    each do |e|
      [*e].each { |f| res << f }
    end

    replace(res)
  end

  def flatten_once
    dup.f​​latten_once!
  end
end

在短的区块使用 reduce 时,把参数命名为 |a, e| (累加器,元素)。

当定义二元操作符时,把参数命名为 other 。

def +(other)
  # body omitted
end

偏好 map 胜于 collect , find 胜于 detect , select 胜于 find_all , reduce 胜于 inject 以及 size 胜于 length 。这不是一个硬性要求;如果使用别名增加了可读性,使用它没关系。

这些有押韵的方法名是从 Smalltalk 继承而来,在别的语言不常见。鼓励使用 select 而不是 find_all 的理由是它跟 reject 搭配起来是一目了然的。

注释 (Comments)

撰写自我记录的代码然后忽略之后的小节。我是认真的!

比一个单词长的注释要大写及使用标点符号。

句号后使用一个空格。

避免冗赘的注释

# 差
counter += 1 # 把计数器加一

保持现有的注释是最新的。过时的注解比没有注解还差。

避免替烂代码写注解。重构代码让它们看起来一目了然。

(要嘛就做,要嘛不做― 不要只是试试看。)

注解 (Annotations)

注解应该直接写在相关代码那行之后。

注解关键字后方,伴随着一个冒号及空白,接着一个描述问题的记录。

如果需要用多行来描述问题,之后的行要放在 # 号后面并缩排两个空白。

def bar # FIXME: 这在v3.2.1 版本之后会异常崩溃,或许与 # BarBazUtil 的版本更新有关 baz(:quux) end

在问题是显而易见的情况下,任何的文档会是多余的,注解应该要留在可能有问题的那行。这个用法是例外而不是规则。

def bar
  sleep 100 # OPTIMIZE
end

使用 TODO 来标记之后应被加入的未实现功能或特色。

使用 FIXME 来标记一个需要修复的代码。

使用 OPTIMIZE 来标记可能影响效能的缓慢或效率低落的代码。

使用 HACK 来标记代码异味,其中包含了可疑的编码实践以及应该需要重构。

使用 REVIEW 来标记任何需要审视及确认正常动作的地方。

举例来说:

REVIEW: 我们确定用户现在是这么做的吗?

如果你觉得是适当的话,使用其他习惯的注解关键字,但记得把它们记录在项目的 README 或类似的地方。

类别

当设计类别阶层时,确认它们符合Liskov 代换原则。 尽可能让你的类别越坚固越好。 永远替类别提供一个适当的 to_s 方法给来表示领域模型。

class Person
  attr_reader :first_name, :last_name

  def initialize(first_name, last_name)
    @first_name = first_name
    @last_name = last_name
  end

  def to_s
    "#@first_name #@last_name"
  end
end

使用 attr 这类函数来定义琐碎的 accessor 或 mutators。

# 差
class Person
  def initialize(first_name, last_name)
    @first_name = first_name
    @last_name = last_name
  end

  def first_name
    @first_name
  end

  def last_name
    @last_name
  end
end

# 好
class Person
  attr_reader :first_name, :last_name

  def initialize(first_name, last_name)
    @first_name = first_name
    @last_name = last_name
  end
end

考虑使用 Struct.new,它替你定义了那些琐碎的存取器(accessors),建构式(constructor)以及比较操作符(comparison operators)。

# 好
class Person
  attr_reader :first_name, :last_name

  def initialize(first_name, last_name)
    @first_name = first_name
    @last_name = last_name
  end
end

# 较佳
class Person < Struct.new (:first_name, :last_name)
end

考虑加入工厂方法来提供额外合理的方式,创造一个特定类别的实体。

class Person
  def self.create(options_hash)
    # body omitted
  end
end

偏好鸭子类型胜于继承。

# 差
class Animal
  # 抽象方法
  def speak
  end
end

# 继承高层级的类别
class Duck < Animal
  def speak
    puts 'Quack! Quack'
  end
end

# 继承高层级的类别
class Dog < Animal
  def speak
    puts 'Bau! Bau!'
  end
end

# 好
class Duck
  def speak
    puts 'Quack! Quack'
  end
end

class Dog
  def speak
    puts 'Bau! Bau!'
  end
end

由于继承中“讨厌的”行为,避免使用类别变量( @@ )。

class Parent
  @@class_var = 'parent'

  def self.print_class_var
    puts @@class_var
  end
end

class Child < Parent
  @@class_var = 'child'
end

Parent.print_class_var # => will print "child"

如同你所看到的,在类别阶级中的所有类别其实都共享一个类别变量。
应该通常偏好使用实体变量而不是类别变量。

依据方法的目的用途指定适当的可视层级(private , protected )。

别把所有方法都设为 public (方法的缺省值)。

我们现在是在写 Ruby ,不是 Python 。

public, protected, private 和方法定义有一样的缩排。

在每一个上方留一个空行。

class SomeClass
  def public_method
    # ...
  end

  private
  def private_method
    # ...
  end
end

使用def slef.method 来定义singleton 方法。这让方法更能抵抗重构带来的变化。

class TestClass
  # 差
  def TestClass.some_method
    # body omitted
  end

  # 好
  def self.some_other_method
    # body omitted
  end

  # 也有可能及当你要定义多个
  # singleton时的便利方法
  class << self
    def first_method
      # body omitted
    end

    def second_method_etc
      # body omitted
    end
  end
end

异常

不要封锁异常。

begin
  # 这里发生了一个异常
rescue SomeError
  # 拯救子句完全没有做事
end

不要为了控制流程而使用异常。

# 差
begin
  n / d
rescue ZeroDivisionError
  puts "Cannot divide by 0!"
end

# 好
if d.zero?
  puts "Cannot divide by 0!"
else
  n / d
end

避免救援 Exception 类别。这会把信号困住,并呼叫 exit,导致你需要 kill -9 进程。

# 不好
begin
  # 呼叫 exit 及杀掉信号会被捕捉(除了 kill -9)
  exit
rescue Exception
  puts "you didn't really want to exit, right?"
  # 异常处理
end

# 好
begin
  # 从StandardError 中救援一个救援子句,
  # 不是许多程式设计师所假定的异常。
rescue => e
  # 异常处理
end

# 也很好
begin
  # 这里发生一个异常

rescue StandardError => e
  # 异常处理
end

把较具体的异常放在救援串连的较上层,不然它们永远不会被拯救。

# 差
begin
  # 一些代码
rescue Exception => e
  # 一些处理
rescue StandardError => e
  # 一些处理
end

# 好
begin
  # 一些代码
rescue StandardError => e
  # 一些处理
rescue Exception => e
  # 一些处理
end

在 ensure 区块中释放你的程式的外部资源。

f = File.open("testfile")
begin
  # .. 处理
rescue
  # .. 错误处理
ensure
  f.close unless f.nil?
end

偏爱使用标准函式库的异常处理胜于导入新的异常类别。

集合

当你需要使用一个字串的数组时,偏好使用 %w 的字面数组语法。

# 差
STATES = ['draft', 'open', 'closed']

# 好
STATES = %w(draft open closed)

避免在数组中创造巨大的间隔。

arr = []
arr[100] = 1 # 现在你有一个很多 nil 的数组

当处理独一无二的元素时,使用 Set 来替代 Array 。 Set 实现了不重复的无序数值集合。 Set 是数组直观的内部操作功能与哈希的快速存取的混合体。

使用符号取代字串作为哈希键。

# 差
hash = { 'one' => 1, 'two' => 2, 'three' => 3 }

# 好
hash = { one: 1, two: 2, three: 3 }

避免使用可变的对象作为键值。

优先使用新的 1.9 字面哈希语法而不是 => (hashrocket) 语法。

# 差
hash = { :one => 1, :two => 2, :three => 3 }

# 好
hash = { one: 1, two: 2, three: 3 }

信任这个事实, 1.9 的哈希是有序的。

在遍历一个集合时,不要改动它。

字串

偏好字串插值(interpolation),而不是字串串接(concatenation)。

# 差
email_with_name = user.name + ' <' + user.email + '>'

# 好
email_with_name = "#{user.name} <#{user.email}>"

当你不需要插入特殊符号如 \t, \n, ', 等等时,偏好单引号的字串。

# 差
name = "Bozhidar"

# 好
name = 'Bozhidar'

不要使用 {} 围绕要被插入字串的实体变量。

class Person
  attr_reader :first_name, :last_name

  def initialize(first_name, last_name)
    @first_name = first_name
    @last_name = last_name
  end

  # 差
  def to_s
    "#{@first_name} #{@last_name}"
  end

  # 好
  def to_s
    "#@first_name #@last_name"
  end
end

当你需要建构庞大的资料区块(chunk)时,避免使用 String#+ 。使用 String#<< 来替代。字串串接在对的地方改变字串实体,并且永远比 String#+ 来得快,String#+ 创造了一堆新的字串对象。

# 好也比较快
html = ''
html << '<h1>Page title</h1>'

paragraphs.each do |paragraph|
  html << "<p>#{paragraph}</p>"
end

正則表示法

如果你只需要在字串中简单的搜索文字,不要使用正則表示法:string['text']

针对简单的字串查询,你可以直接在字串索引中直接使用正則表示法。

match = string[/regexp/] # 获得匹配正則表示法的内容
first_group = string[/text(grp)/, 1] # 或得分组的内容
string[/text (grp)/, 1] = 'replace' # string => 'text replace'

当你不需要替结果分组时,使用非分组的群组。

/(first|second)/ # 差
/(?:first|second)/ # 好

避免使用 $1-9,因为它们很难追踪它们包含什么。可以使用命名群组来替代。

# 差
/(regexp)/ =~ string
...
process $1

# 好
/(?<meaningful_var>regexp)/ =~ string
...
process meaningful_var

字符类别只有几个你需要关心的特殊字元:^, -, \, ],所以你不用逃脱字元 . 或在 [] 的中括号。

小心使用 ^ 与 $ ,它们匹配的是一行的开始与结束,​​不是字串的开始与结束。如果你想要匹配整个字串,使用 \A 与 \z。

(译注:\Z 实为 /\n?\z/,使用 \z 才能匹配到有含新行的字串的结束)

string = "some injection\nusername"
string[/^username$/] # matches
string[/\Ausername\z/] # don't match

针对复杂的正則表示法,使用 x 修饰符。这让它们的可读性更高并且你可以加入有用的注释。只是要小心忽略的空白。

regexp = %r{
  start # 一些文字
  \s # 空白字元
  (group) # 第一组
  (?:alt1|alt2) # 一些替代方案
  end
}x

针对复杂的替换,sub 或 gsub 可以与区块或哈希来使用。

百分比字面

随意使用 %w 。

STATES = %w(draft open closed)

使用 %() 给需要插值与嵌入双引号的单行字串。多行字串,偏好使用 heredocs 。

# 差(不需要插值)
%(<div class="text">Some text</div>)
# 应该使用'<div class="text">Some text</div>'

# 差(没有双引号)
%(This is #{quality} style)
# 应该使用 "This is #{quality} style"

# 差(多行)
%(<div>\n<span class="big">#{exclamation}</span>\n</div>)
# 应该是一个 heredoc

# 好(需要插值、有双引号以及单行)
%(<tr><td class="name">#{name}</td>)

正則表示法要匹配多于一个的 / 字元时,使用 %r。

# 差
%r(\s+)

# 仍然差
%r(^/(.*)$)
# 应当是 /^\/(.*)$/

# 好
%r(^/blog/2011/(.*)$)

避免 %q, %Q, %x, %s 以及 %W。 偏好 () 作为所有 % 字面的分隔符。

元编程

写一个函式库时不要在核心类别捣乱(不要替它们加 monkey patch)

偏好区块形式的 class_eval 胜于字串插值(string-interpolated)的形式。

当你使用字串插值形式时,总是提供 __FILE__ 及 __LINE__,使你的 backtrace 看起来有意义:

class_eval "def use_relative_model_naming?; true; end", __FILE__, __LINE__

偏好 define_method 胜于 class_eval{ def ... }

当使用 class_eval (或其它的eval)搭配字串插值时,添加一个注解区块,来演示如果做了插值的样子(我从 Rails 代码学来的一个实践):

# 从 activesupport/lib/active_support/core_ext/string/output_safety.rb
UNSAFE_STRING_METHODS.each do |unsafe_method|
  if 'String'.respond_to?(unsafe_method)
    class_eval <<-EOT, __FILE__, __LINE__ + 1
      def #{unsafe_method}(*args, &block)       # def capitalize(*args, &block)
        to_str.#{unsafe_method}(*args, &block)  #   to_str.capitalize(*args, &block)
      end                                       # end

      def #{unsafe_method}!(*args)              # def capitalize!(*args)
        @dirty = true                           #   @dirty = true
        super                                   #   super
      end                                       # end
    EOT
  end
end

元编程避免使用 method_missing。会让 Backtraces 变得很凌乱;行为没有列在 #methods 里;拼错的方法呼叫可能默默的工作(nukes.luanch_state = false)。考虑使用 delegation, proxy, 或是 define_method 来取代。如果你必须使用 method_missing 。确保也定义了respond_to?。

仅捕捉字首定义良好的方法,像是 find_by_* ― 让你的代码愈肯定(assertive)愈好。

在最后的叙述句(statement)呼叫 super 。

从 delegate 到 assertive, 不神奇的(non-magical) methods:

# 差
def method_missing?(meth, *args, &block)
  if /^find_by_(?<prop>.*)/ =~ meth
    # ... lots of code to do a find_by
  else
    super
  end
end

# 好
def method_missing?(meth, *args, &block)
  if /^find_by_(?<prop>.*)/ =~ meth
    find_by(prop, *args, &block)
  else
    super
  end
end

# 而最好是在每个可以找到的属性被宣告时,使用 define_method。

其它

ruby -w 写安全的代码。

避免使用哈希作为选择性参数。这个方法是不是做太多事了?

避免方法长于 10 行代码(LOC)。理想上,大部分的方法会小于 5 行。空行不算进LOC里。

避免参数列表长于三或四个参数。

如果你真的需要,加入“全域”变量到核心以及把它们设为私有的。

使用类别变量而不是全域变量。

# 差
$foo_bar = 1

# 好
class Foo
  class << self
    attr_accessor ​​:bar
  end
end

Foo.bar = 1

当 alias_method 可以做到时,避免使用 alias 。

使用 OptionParser 来解析复杂的命令行选项及 ruby -s 来处理琐碎的命令行选项。 用函数式的方法编程,在有意义的情况下避免赋值。

避免不需要的元编程。

不要变动参数,除非那是方法的目的。

避免超过三行的区块嵌套。

保持一致性。

在理想的世界里,遵循这些准则。

使用常识。

貢獻

在本指南所寫的每個東西都不是定案。這只是我渴望想與同樣對 Ruby 程式設計風格有興趣的大家一起工作,以致於最終我們可以替整個 Ruby 社群創造一個有益的資源。

歡迎開票或發送一個帶有改進的更新請求。在此提前感謝你的幫助!

口耳相傳

一份社群策動的風格指南,對一個社群來說,只是讓人知道有這個社群。推特這個指南,分享給你的朋友或同事。我們得到的每個註解、建議或意見都可以讓這份指南變得更好一點。而我們想要擁有的是最好的指南,不是嗎?

Rails 风格指导

序幕

这份指南目的于演示一整套 Rails 3 开发的风格惯例及最佳实践。这是一份与由现存社群所驱动的Ruby 编码风格指南互补的指南。

而本指南中测试 Rails 应用小节摆在开发 Rails 应用之后,因为我相信行为驱动开发 (BDD) 是最佳的软体开发之道。铭记在心吧。

Rails 是一个坚持己见的框架,而这也是一份坚持己见的指南。在我的心里,我坚信 RSpec 优于 Test::Unit,Sass 优于 CSS 以及 Haml,(Slim) 优于 Erb。所以不要期望在这里找到 Test::Unit, CSS 及 Erb 的忠告。

某些忠告仅适用于 Rails 3.1+ 以上版本。

你可以使用 Transmuter 来产生本指南的一份 PDF 或 HTML 复本。

开发 Rails 应用程序

配置

把惯用的初始化代码放在 config/initializers。 在 initializers 内的代码于应用启动时执行。

每一个 gem 相关的初始化代码应当使用同样的名称,放在不同的文件里,如: carrierwave.rb, active_admin.rb, 等等。

相应调整配置开发、测试及生产环境(在 config/environments/ 下对应的文件)

标记额外的资产给(如有任何)预编译:

# config/environments/production.rb
# 预编译额外的资产(application.js, application.css, 以及所有已经被加入的非 JS 或 CSS 的文件)
config.assets.precompile += %w( rails_admin/rails_admin.css rails_admin/rails_admin.js )

创立一个与生产环境(production enviroment)相似的额外 staging 环境。

路由

当你需要加入一个或多个动作至一个 RESTful 资源时(你真的需要吗?),使用 member and collection 路由。

# 差
get 'subscriptions/:id/unsubscribe'
resources :subscriptions

# 好
resources :subscriptions do
  get 'unsubscribe', :on => :member
end

# 差
get 'photos/search'
resources :photos

# 好
resources :photos do
  get 'search', :on => :collection
end

若你需要定义多个 member/collection 路由时,使用替代的区块语法(block syntax)。

resources :subscriptions do
  member do
    get 'unsubscribe'
    # 更多路由
  end
end

resources :photos do
  collection do
    get 'search'
    # 更多路由
  end
end

使用嵌套路由(nested routes)来更佳地表达与 ActiveRecord 模型的关系。

class Post < ActiveRecord::Base
  has_many :comments
end

class Comments < ActiveRecord::Base
  belongs_to :post
end

# routes.rb
resources :posts do
  resources :comments
end

使用命名空间路由来群组相关的行为。

namespace :admin do
  # Directs /admin/products/to Admin::ProductsController
  # (app/controllers/admin/products_controller.rb)
  resources :products
end

不要在控制器里使用留给后人般的疯狂路由(legacy wild controller route)。这种路由会让每个控制器的动作透过 GET 请求存取。

# 非常差
match ':controller(/:action(/:id(.:format)))'

控制器

让你的控制器保持苗条 ― 它们应该只替视图层取出数据且不包含任何业务逻辑(所有业务逻辑应当放在模型里)。

每个控制器的行动应当(理想上)只调用一个除了初始的 find 或 new 方法。

控制器与视图之间共享不超过两个实例变量(instance variable)。

模型

自由地引入不是 ActiveRecord 的类别吧。

替模型命名有意义(但简短)且不带缩写的名字。

如果你需要支援 ActiveRecord 像是验证行为的模型对象,使用 ActiveAttr gem。

class Message
  include ActiveAttr::Model

  attribute :name
  attribute :email
  attribute :content
  attribute :priority

  attr_accessible :name, :email, :content

  validates_presence_of :name
  validates_format_of :email, :with => /^[-a-z0-9_+\.]+\@([-a-z0-9]+\.)+[a-z0-9]{2,4}$/i
  validates_length_of :content, :maximum => 500
end

更完整的示例,参考 [RailsCast on the subject](http://railscasts.com/episodes/326-activeattr)。

ActiveRecord

避免改动缺省的 ActiveRecord(表的名字、主键,等等),除非你有一个非常好的理由(像是不受你控制的数据库)。

把宏风格的方法放在类别定义的前面(has_many, validates, 等等)。

偏好 has_many :through 胜于 has_and_belongs_to_many。

使用 has_many :through 允许在 join 模型有附加的属性及验证

# 使用 has_and_belongs_to_many
class User < ActiveRecord::Base
  has_and_belongs_to_many :groups
end

class Group < ActiveRecord::Base
  has_and_belongs_to_many :users
end

# 偏好方式 - using has_many :through
class User < ActiveRecord::Base
  has_many :memberships
  has_many :groups, through: :memberships
end

class Membership < ActiveRecord::Base
  belongs_to :user
  belongs_to :group
end

class Group < ActiveRecord::Base
  has_many :memberships
  has_many :users, through: :memberships
end

使用新的 “sexy” validation。

当一个惯用的验证使用超过一次或验证是某个正则表达映射时,创建一个惯用的 validator 文件。

# 差
class Person
  validates :email, format: { with: /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i }
end

# 好
class EmailValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    record.errors[attribute] << (options[:message] || 'is not a valid email') unless value =~ /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i
  end
end

class Person
  validates :email, email: true
end

所有惯用的验证器应放在一个共享的 gem 。

自由地使用命名的作用域(scope)。

当一个由 lambda 及参数定义的作用域变得过于复杂时,更好的方式是建一个作为同样用途的类别方法,并返回 ActiveRecord::Relation 对象。

注意 update_attribute 方法的行为。

它不运行模型验证(不同于 update_attributes )并且可能把模型状态给搞砸。

使用用户友好的网址。在网址显示具描述性的模型属性,而不只是 id 。

有不止一种方法可以达成:

覆写模型的 to_param 方法。这是 Rails 用来给对象建构网址的方法。缺省的实作会以字串形式返回该 id 的记录。它可被另一个具人类可读的属性覆写。

class Person
  def to_param
    "#{id} #{name}".parameterize
  end
end

为了要转换成对网址友好 (URL-friendly)的数值,字串应当调用 parameterize。 对象的 id 要放在开头,以便给 ActiveRecord 的 find 方法查找。

使用此 friendly_id gem。它允许藉由某些具描述性的模型属性,而不是用 id 来创建人类可读的网址。

class Person
  extend FriendlyId
  friendly_id :name, use: :slugged
end

查看 gem 文档获得更多关于使用的信息。

ActiveResource

当 HTTP 响应是一个与存在的格式不同的格式时(XML 和 JSON),需要某些额外的格式解析,创一个你惯用的格式,并在类别中使用它。惯用的格式应当实作下列方法:extension, mime_type, encode 以及 decode。

module ActiveResource
  module Formats
    module Extend
      module CSVFormat
        extend self

        def extension
          'csv'
        end

        def mime_type
          'text/csv'
        end

        def encode(hash, options = nil)
          # 数据以新格式编码并返回
        end

        def decode(csv)
          # 数据以新格式解码并返回
        end
      end
    end
  end
end

class User < ActiveResource::Base
  self.format = ActiveResource::Formats::Extend::CSVFormat

  ...
end

若 HTTP 请求应当不扩展发送时,覆写 ActiveResource::Base 的 element_path 及 collection_path 方法,并移除扩展的部份。

class User < ActiveResource::Base
  ...

  def self.collection_path(prefix_options = {}, query_options = nil)
    prefix_options, query_options = split_options(prefix_options) if query_options.nil?
    "#{prefix(prefix_options)}#{collection_name}#{query_string(query_options)}"
  end

  def self.element_path(id, prefix_options = {}, query_options = nil)
    prefix_options, query_options = split_options(prefix_options) if query_options.nil?
    "#{prefix(prefix_options)}#{collection_name}/#{URI.parser.escape id.to_s}#{query_string(query_options)}"
  end
end

如有任何改动网址的需求时,这些方法也可以被覆写。

迁移

把 schema.rb 保存在版本管控之下。

使用 rake db:scheme:load 取代 rake db:migrate 来初始化空的数据库。

使用 rake db:test:prepare 来更新测试数据库的 schema。

避免在表里设置缺省数据。使用模型层来取代。

def amount
  self[:amount] or 0
end

然而 self[:attr_name] 的使用被视为相当常见的,你也可以考虑使用
更罗嗦的(争议地可读性更高的)read_attribute 来取代:

def amount
  read_attribute(:amount) or 0
end

当编写建设性的迁移时(加入表或栏位),使用 Rails 3.1 的新方式来迁移 - 使用 change 方法取代 up 与 down 方法。

# 过去的方式
class AddNameToPerson < ActiveRecord::Migration
  def up
    add_column :persons, :name, :string
  end

  def down
    remove_column :person, :name
  end
end

# 新的偏好方式
class AddNameToPerson < ActiveRecord::Migration
  def change
    add_column :persons, :name, :string
  end
end

视图

不要直接从视图调用模型层。

不要在视图构造复杂的格式,把它们输出到视图 helper 的一个方法或是模型。

使用 partial 模版与布局来减少重复的代码。

加入 client side validation 至惯用的 validators。 要做的步骤有:

声明一个由 ClientSideValidations::Middleware::Base 而来的自定 validator

    module ClientSideValidations::Middleware
      class Email < Base
        def response
          if request.params[:email] =~ /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i
            self.status = 200
          else
            self.status = 404
          end
          super
        end
      end
    end

建立一个新文件 public/javascripts/rails.validations.custom.js.coffee 并在你的 application.js.coffee 文件加入一个它的参照:

    # app/assets/javascripts/application.js.coffee
    #= require rails.validations.custom

添加你的用户端 validator:

    #public/javascripts/rails.validations.custom.js.coffee
    clientSideValidations.validators.remote['email'] = (element, options) ->
      if $.ajax({
        url: '/validators/email.json',
        data: { email: element.val() },
        async: false
      }).status == 404
        return options.message || 'invalid e-mail format'

国际化

视图、模型与控制器里不应使用语言相关设置与字串。这些文字应搬到在 config/locales 下的语言文件里。

当 ActiveRecord 模型的标签需要被翻译时,使用 activerecord 作用域:

en:
  activerecord:
    models:
      user: Member
    attributes:
      user:
        name: "Full name"

然后 User.model_name.human 会返回 “Member” ,
而 User.human_attribute_name("name") 会返回 "Full name"。
这些属性的翻译会被视图作为标签使用。

把在视图使用的文字与 ActiveRecord 的属性翻译分开。 把给模型使用的语言文件放在名为 models 的文件夹,给视图使用的文字放在名为 views 的文件夹。

当使用额外目录的语言文件组织完成时,为了要载入这些目录,要在 application.rb 文件里描述这些目录。

# config/application.rb
config.i18n.load_path += Dir[Rails.root.join('config', 'locales', '**', '*.{rb,yml}')]

把共享的本地化选项,像是日期或货币格式,放在 locales 的根目录下。

使用精简形式的 I18n 方法: I18n.t 来取代 I18n.translate 以及使用 I18n.l 取代 I18n.localize。

使用 “惰性” 查询视图中使用的文字。假设我们有以下结构:

en:
  users:
    show:
      title: "User details page"

users.show.title 的数值能这样被 app/views/users/show.html.haml 查询:

= t '.title'

在控制器与模型使用点分隔的键,来取代指定 :scope 选项。点分隔的调用更容易阅读及追踪层级。

# 这样子调用
I18n.t 'activerecord.errors.messages.record_invalid'

# 而不是这样
I18n.t :record_invalid, :scope => [:activerecord, :errors, :messages]

关于 Rails i18n 更详细的信息可以在这里找到 Rails Guides。

Assets

利用这个 assets pipeline 来管理应用的结构。

保留 app/assets 给自定的样式表, javascripts, 或图片。

第三方代码如: jQuery 或 bootstrap 应放置在 vendor/assets。

当可能的时候,使用 gem 化的 assets 版本。(如: jquery-rails)。

Mailers

把 mails 命名为 SomethingMailer。 没有 Mailer 字根的话,不能立即显现哪个是一个 Mailer,以及哪个视图与它有关。

提供 HTML 与纯文本视图模版。

在你的开发环境启用信件失败发送错误。这些错误缺省是被停用的。

# config/environments/development.rb

config.action_mailer.raise_delivery_errors = true

在开发模式使用 smtp.gmail.com 设置 SMTP 服务器(当然了,除非你自己有本地 SMTP 服务器)。

# config/environments/development.rb

config.action_mailer.smtp_settings = {
  address: 'smtp.gmail.com',
  # 更多设置
}

提供缺省的配置给主机名。

# config/environments/development.rb
config.action_mailer.default_url_options = {host: "#{local_ip}:3000"}


# config/environments/production.rb
config.action_mailer.default_url_options = {host: 'your_site.com'}

# 在你的 mailer 类
default_url_options[:host] = 'your_site.com'

如果你需要在你的网站使用一个 email 链结,总是使用 _url 方法,而不是 _path 方法。 _url 方法包含了主机名,而 _path 方法没有。

# 错误
You can always find more info about this course
= link_to 'here', url_for(course_path(@course))

# 正确
You can always find more info about this course
= link_to 'here', url_for(course_url(@course))

正确地显示寄与收件人地址的格式。使用下列格式:

# 在你的 mailer 类别
default from: 'Your Name <info@your_site.com>'

确定测试环境的 email 发送方法设置为 test :

# config/environments/test.rb

config.action_mailer.delivery_method = :test

开发与生产环境的发送方法应为 smtp :

# config/environments/development.rb, config/environments/production.rb

config.action_mailer.delivery_method = :smtp

当发送 HTML email 时,所有样式应为行内样式,由于某些用户有关于外部样式的问题。某种程度上这使得更难管理及造成代码重用。有两个相似的 gem 可以转换样式,以及将它们放在对应的 html 标签里: premailer-rails3 和 roadie。

应避免页面产生响应时寄送 email。若多个 email 寄送时,造成了页面载入延迟,以及请求可能逾时。使用 delayed_job gem 的帮助来克服在背景处理寄送 email 的问题。

Bundler

把只给开发环境或测试环境的 gem 适当地分组放在 Gemfile 文件中。

在你的项目中只使用公认的 gem。 如果你考虑引入某些显为人所知的 gem ,你应该先仔细复查一下它的源代码。

关于多个开发者使用不同操作系统的项目,操作系统相关的 gem 缺省会产生一个经常变动的 Gemfile.lock 。 在 Gemfile 文件里,所有与 OS X 相关的 gem 放在 darwin 群组,而所有 Linux 相关的 gem 放在 linux 群组:

# Gemfile
group :darwin do
  gem 'rb-fsevent'
  gem 'growl'
end

group :linux do
  gem 'rb-inotify'
end

要在对的环境获得合适的 gem,添加以下代码至 config/application.rb :

platform = RUBY_PLATFORM.match(/(linux|darwin)/)[0].to_sym
Bundler.require(platform)

不要把 Gemfile.lock 文件从版本控制里移除。这不是随机产生的文件 - 它确保你所有的组员执行 bundle install 时,获得相同版本的 gem 。

无价的 Gems

一个最重要的编程理念是 "不要重造轮子!" 。若你遇到一个特定问题,你应该要在你开始前,看一下是否有存在的解决方案。下面是一些在很多 Rails 项目中 "无价的" gem 列表(全部兼容 Rails 3.1):

active_admin - 有了 ActiveAdmin,创建 Rails 应用的管理介面就像儿戏。你会有一个很好的仪表盘,图形化 CRUD 介面以及更多东西。非常灵活且可客制化。

capybara - Capybara 旨在简化整合测试 Rack 应用的过程,像是 Rails、Sinatra 或 Merb。Capybara 模拟了真实用户使用 web 应用的互动。 它与你测试在运行的驱动无关,并原生搭载 Rack::Test 及 Selenium 支持。透过外部 gem 支持 HtmlUnit、WebKit 及 env.js 。与 RSpec & Cucumber 一起使用时工作良好。

carrierwave - Rails 最后一个文件上传解决方案。支持上传档案(及很多其它的酷玩意儿的)的本地储存与云储存。图片后处理与 ImageMagick 整合得非常好。

clientsidevalidations - 一个美妙的 gem,替你从现有的服务器端模型验证自动产生 Javascript 用户端验证。高度推荐!

compass-rails - 一个优秀的 gem,添加了某些 css 框架的支持。包括了 sass mixin 的蒐集,让你减少 css 文件的代码并帮你解决浏览器兼容问题。

cucumber-rails - Cucumber 是一个由 Ruby 所写,开发功能测试的顶级工具。 cucumber-rails 提供了 Cucumber 的 Rails 整合。

devise - Devise 是 Rails 应用的一个完整解决方案。多数情况偏好使用 devise 来开始你的客制验证方案。

fabrication - 一个很好的假数据产生器(编辑者的选择)。

factory_girl - 另一个 Fabrication 的选择。一个成熟的假数据产生器。 Fabrication 的精神领袖先驱。

faker - 实用的 gem 来产生仿造的数据(名字、地址,等等)。

feedzirra - 非常快速及灵活的 RSS 或 Atom 种子解析器。

friendly_id - 透过使用某些具描述性的模型属性,而不是使用 id,允许你创建人类可读的网址。

guard - 极佳的 gem 监控文件变化及任务的调用。搭载了很多实用的扩充。远优于 autotest 与 watchr。

haml-rails - haml-rails 提供了 Haml 的 Rails 整合。

haml - Haml 是一个简洁的模型语言,被很多人认为(包括我)远优于 Erb。

kaminari - 很棒的分页解决方案。

machinist - 假数据不好玩,Machinist 才好玩。

rspec-rails - RSpec 是 Test::MiniTest 的取代者。我不高度推荐 RSpec。 rspec-rails 提供了 RSpec 的 Rails 整合。

simpleform - 一旦用过 simpleform(或 formatastic),你就不想听到关于 Rails 缺省的表单。它是一个创造表单很棒的DSL。

simplecov-rcov - 为了 SimpleCov 打造的 RCov formatter。若你想使用 SimpleCov 搭配 Hudson 持续整合服务器,很有用。

simplecov - 代码覆盖率工具。不像 RCov,完全兼容 Ruby 1.9。产生精美的报告。必须用!

slim - Slim 是一个简洁的模版语言,被视为是远远优于 HAML(Erb 就更不用说了)的语言。唯一会阻止我大规模地使用它的是,主流 IDE 及编辑器对它的支持不好。但它的效能是非凡的。

spork - 一个给测试框架(RSpec 或 现今 Cucumber)用的 DRb 服务器,每次运行前确保分支出一个乾净的测试状态。 简单的说,预载很多测试环境的结果是大幅降低你的测试启动时间,绝对必须用!

sunspot - 基于 SOLR 的全文检索引擎。

这不是完整的清单,以及其它的 gem 也可以在之后加进来。以上清单上的所有 gems 皆经测试,处于活跃开发阶段,有社群以及代码的质量很高。

缺陷的 Gems

这是一个有问题的或被别的 gem 取代的 gem 清单。你应该在你的项目里避免使用它们。

rmagick - 这个 gem 因大量消耗内存而声名狼藉。使用 minimagick 来取代。

autotest - 自动测试的老旧解决方案。远不如 guard 及 watchr。

rcov - 代码覆盖率工具,不兼容 Ruby 1.9。使用 SimpleCov 来取代。

therubyracer - 极度不鼓励在生产模式使用这个 gem,它消耗大量的内存。我会推荐使用 Mustang 来取代。

这仍是一个完善中的清单。请告诉我受人欢迎但有缺陷的 gems 。

管理进程

若你的项目依赖各种外部的进程使用 foreman 来管理它们。

测试 Rails 应用

也许 BDD 方法是实作一个新功能最好的方法。你从开始写一些高阶的测试(通常使用 Cucumber),然后使用这些测试来驱使你实作功能。一开始你给功能的视图写测试,并使用这些测试来创建相关的视图。之后,你创建丢给视图数据的控制器测试来实现控制器。最后你实作模型的测试以及模型自身。

Cucumber

用 @wip (工作进行中)标签标记你未完成的场景。这些场景不纳入考虑,且不标记为测试失败。当完成一个未完成场景且功能测试通过时,为了把此场景加至测试套件里,应该移除 @wip 标签。

配置你的缺省配置文件,排除掉标记为 @javascript 的场景。它们使用浏览器来测试,推荐停用它们来增加一般场景的执行速度。 替标记著 @javascript 的场景配置另一个配置文件。

配置文件可在 cucumber.yml 文件里配置。

# 配置文件的定义:
profile_name: --tags @tag_name

带指令运行一个配置文件:

cucumber -p profile_name

若使用 fabrication 来替换假数据 (fixtures),使用预定义的 fabrication steps。

不要使用旧版的 web_steps.rb 步骤定义!最新版 Cucumber 已移除 web steps,使用它们导致冗赘的场景,而且它并没有正确地反映出应用的领域。

当检查一元素的可视文字时,检查元素的文字而不是检查 id。这样可以查出 i18n 的问题。

给同种类对象创建不同的功能特色:

# 差
Feature: Articles
# ... 功能实作 ...

# 好
Feature: Article Editing
# ... 功能实作 ...

Feature: Article Publishing
# ... 功能实作 ...

Feature: Article Search
# ... 功能实作 ...

每一个功能有三个主要成分:

Title 标题

Narrative - 简短说明这个特色关于什么。

Acceptance criteria - 每个由独立步骤组成的一套场景。

最常见的格式称为 Connextra 格式。

In order to [benefit] ...
A [stakeholder]...
Wants to [feature] ...

这是最常见但不是要求的格式,叙述可以是依赖功能复杂度的任何文字。

自由地使用场景概述使你的场景备作它用 (keep your scenarios DRY)。

Scenario Outline: User cannot register with invalid e-mail
  When I try to register with an email "<email>"
  Then I should see the error message "<error>"

Examples:
  |email         |error                 |
  |              |The e-mail is required|
  |invalid email |is not a valid e-mail |

场景的步骤放在 step_definitions 目录下的 .rb 文件。

步骤文件命名惯例为 [description]_steps.rb。

步骤根据不同的标准放在不同的文件里。

每一个功能可能有一个步骤文件 ( home_page_steps.rb )。

也可能给每个特定对象的功能,建一个步骤文件 (articles_steps.rb)。

使用多行步骤参数来避免重复

场景: User profile
  Given I am logged in as a user "John Doe" with an e-mail "user@test.com"
  When I go to my profile
  Then I should see the following information:
    |First name|John         |
    |Last name |Doe          |
    |E-mail    |user@test.com|

# 步骤:
Then /^I should see the following information:$/ do |table|
  table.raw.each do |field, value|
    find_field(field).value.should =~ /#{value}/
  end
end

使用复合步骤使场景备作它用 (Keep your scenarios DRY)

# ...
When I subscribe for news from the category "Technical News"
# ...

# 步骤:
When /^I subscribe for news from the category "([^"]*)"$/ do |category|
  steps %Q{
    When I go to the news categories page
    And I select the category #{category}
    And I click the button "Subscribe for this category"
    And I confirm the subscription
  }
end

总是使用 Capybara 否定匹配来取代正面情况搭配 should_not,它们会在给定的超时时重试匹配,允许你测试 ajax 动作。见 Capybara 的 读我文件获得更多说明。

RSpec

一个例子仅用一个期望值。

# 差
describe ArticlesController do
  #...

  describe 'GET new' do
    it 'assigns new article and renders the new article template' do
      get :new
      assigns[:article].should be_a_new Article
      response.should render_template :new
    end
  end

  # ...
end

# 好
describe ArticlesController do
  #...

  describe 'GET new' do
    it 'assigns a new article' do
      get :new
      assigns[:article].should be_a_new Article
    end

    it 'renders the new article template' do
      get :new
      response.should render_template :new
    end
  end

end

大量使用 descibe 及 context 。

如下地替 describe 区块命名:

非方法使用 “description”

实例方法使用井字号 “#method”

类别方法使用点 “.method”

class Article
  def summary
    #...
  end

  def self.latest
    #...
  end
end

# the spec...
describe Article
  describe '#summary'
    #...
  end

  describe '.latest'
    #...
  end
end

使用 fabricators 来创建测试对象。

大量使用 mocks 与 stubs。

# mocking 一个模型
article = mock_model(Article)

# stubbing 一个方法
Article.stub(:find).with(article.id).and_return(article)

当 mocking 一个模型时,使用 as_null_object 方法。它告诉输出仅监听我们预期的讯息,并忽略其它的讯息。

article = mock_model(Article).as_null_object

使用 let 区块而不是 before(:all) 区块替 spec 例子创建数据。let 区块会被惰性求值。

# 使用这个:
let(:article) { Fabricate(:article) }

# ... 而不是这个:
before(:each) { @article = Fabricate(:article) }

当可能时,使用 subject。

describe Article do
  subject { Fabricate(:article) }

  it 'is not published on creation' do
    subject.should_not be_published
  end
end

如果可能的话,使用 specify。它是 it 的同义词,但在没 docstring 的情况下可读性更高。

# 差
describe Article do
  before { @article = Fabricate(:article) }

  it 'is not published on creation' do
    @article.should_not be_published
  end
end

# 好
describe Article do
  let(:article) { Fabricate(:article) }
  specify { article.should_not be_published }
end

当可能时,使用 its 。

# 差
describe Article do
  subject { Fabricate(:article) }

  it 'has the current date as creation date' do
    subject.creation_date.should == Date.today
  end
end

# 好
describe Article do
  subject { Fabricate(:article) }
  its(:creation_date) { should == Date.today }
end

视图

视图测试的目录结构要与 app/views 之中的相符。

举例来说,在 app/views/users 视图被放在 spec/views/users。

视图测试的命名惯例是添加 _spec.rb 至视图名字之后,举例来说,视图 _form.html.haml 有一个对应的测试叫做 _form.html.haml_spec.rb。

每个视图测试文件都需要 spec_helper.rb。

外部描述区块使用不含 app/views 部分的视图路径。 render 方法没有传入参数时,是这么使用的。

# spec/views/articles/new.html.haml_spec.rb
require 'spec_helper'

describe 'articles/new.html.haml' do
  # ...
end

永远在视图测试来 mock 模型。视图的目的只是显示信息。

assign 方法提供由控制器提供视图使用的实例变量(instance variable)。

# spec/views/articles/edit.html.haml_spec.rb
describe 'articles/edit.html.haml' do
it 'renders the form for a new article creation' do
  assign(
    :article,
    mock_model(Article).as_new_record.as_null_object
  )
  render
  rendered.should have_selector('form',
    method: 'post',
    action: articles_path
  ) do |form|
    form.should have_selector('input', type: 'submit')
  end
end

偏好 capybara 否定情况选择器,胜于搭配正面情况的 should_not 。

# 差
page.should_not have_selector('input', type: 'submit')
page.should_not have_xpath('tr')

# 好
page.should have_no_selector('input', type: 'submit')
page.should have_no_xpath('tr')

当一个视图使用 helper 方法时,这些方法需要被 stubbed。Stubbing 这些 helper 方法是在 template 完成的:

# app/helpers/articles_helper.rb
class ArticlesHelper
  def formatted_date(date)
    # ...
  end
end

# app/views/articles/show.html.haml
= "Published at: #{formatted_date(@article.published_at)}"

# spec/views/articles/show.html.haml_spec.rb
describe 'articles/show.html.html' do
  it 'displays the formatted date of article publishing'
    article = mock_model(Article, published_at: Date.new(2012, 01, 01))
    assign(:article, article)

    template.stub(:formatted_date).with(article.published_at).and_return '01.01.2012'

    render
    rendered.should have_content('Published at: 01.01.2012')
  end
end

在 spec/helpers 目录的 helper specs 是与视图 specs 分开的。

控制器

Mock 模型及 stub 他们的方法。测试控制器时不应依赖建模。

仅测试控制器需负责的行为:

执行特定的方法

从动作返回的数据 - assigns, 等等。

从动作返回的结果 - template render, redirect, 等等。

# 常用的控制器 spec 示例
# spec/controllers/articles_controller_spec.rb
# 我们只对控制器应执行的动作感兴趣
# 所以我们 mock 模型及 stub 它的方法
# 并且专注在控制器该做的事情上

describe ArticlesController do
  # 模型将会在测试中被所有控制器的方法所使用
  let(:article) { mock_model(Article) }

  describe 'POST create' do
    before { Article.stub(:new).and_return(article) }

    it 'creates a new article with the given attributes' do
      Article.should_receive(:new).with(title: 'The New Article Title').and_return(article)
      post :create, message: { title: 'The New Article Title' }
    end

    it 'saves the article' do
      article.should_receive(:save)
      post :create
    end

    it 'redirects to the Articles index' do
      article.stub(:save)
      post :create
      response.should redirect_to(action: 'index')
    end
  end
end

当控制器根据不同参数有不同行为时,使用 context。

# 一个在控制器中使用 context 的典型例子是,对象正确保存时,使用创建,保存失败时更新。

describe ArticlesController do
  let(:article) { mock_model(Article) }

  describe 'POST create' do
    before { Article.stub(:new).and_return(article) }

    it 'creates a new article with the given attributes' do
      Article.should_receive(:new).with(title: 'The New Article Title').and_return(article)
      post :create, article: { title: 'The New Article Title' }
    end

    it 'saves the article' do
      article.should_receive(:save)
      post :create
    end

    context 'when the article saves successfully' do
      before { article.stub(:save).and_return(true) }

      it 'sets a flash[:notice] message' do
        post :create
        flash[:notice].should eq('The article was saved successfully.')
      end

      it 'redirects to the Articles index' do
        post :create
        response.should redirect_to(action: 'index')
      end
    end

    context 'when the article fails to save' do
      before { article.stub(:save).and_return(false) }

      it 'assigns @article' do
        post :create
        assigns[:article].should be_eql(article)
      end

      it 're-renders the "new" template' do
        post :create
        response.should render_template('new')
      end
    end
  end
end

模型

不要在自己的测试里 mock 模型。

使用捏造的东西来创建真的对象。

Mock 别的模型或子对象是可接受的。

在测试里建立所有例子的模型来避免重复。

describe Article
  let(:article) { Fabricate(:article) }
end

加入一个例子确保捏造的模型是可行的。

describe Article
  it 'is valid with valid attributes' do
    article.should be_valid
  end
end

当测试验证时,使用 have(x).errors_on 来指定要被验证的属性。使用 be_valid 不保证问题在目的的属性。

# 差
describe '#title'
  it 'is required' do
    article.title = nil
    article.should_not be_valid
  end
end

# 偏好
describe '#title'
  it 'is required' do
    article.title = nil
    article.should have(1).error_on(:title)
  end
end

替每个有验证的属性加另一个 describe 。

describe Article
  describe '#title'
    it 'is required' do
      article.title = nil
      article.should have(1).error_on(:title)
    end
  end
end

当测试模型属性的独立性时,把其它对象命名为 another_object 。

describe Article
  describe '#title'
    it 'is unique' do
      another_article = Fabricate.build(:article, title: article.title)
      article.should have(1).error_on(:title)
    end
  end
end

Mailers

在 Mailer 测试的模型应该要被 mock。 Mailer 不应依赖建模。

Mailer 的测试应该确认如下:

这个 subject 是正确的

这个 receiver e-mail 是正确的

这个 e-mail 寄送至对的邮件地址

这个 e-mail 包含了需要的信息

describe SubscriberMailer
  let(:subscriber) { mock_model(Subscription, email: 'johndoe@test.com', name: 'John Doe') }

  describe 'successful registration email'
    subject { SubscriptionMailer.successful_registration_email(subscriber) }

    its(:subject) { should == 'Successful Registration!' }
    its(:from) { should == ['info@your_site.com'] }
    its(:to) { should == [subscriber.email] }

    it 'contains the subscriber name' do
      subject.body.encoded.should match(subscriber.name)
    end
  end
end

Uploaders

我们如何测试上传器是否正确地调整大小。这里是一个 carrierwave 图片上传器的示例 spec:

# rspec/uploaders/person_avatar_uploader_spec.rb
require 'spec_helper'
require 'carrierwave/test/matchers'

describe PersonAvatarUploader do
  include CarrierWave::Test::Matchers

  # 在执行例子前启用图片处理
  before(:all) do
    UserAvatarUploader.enable_processing = true
  end

  # 创建一个新的 uploader。模型被模仿为不依赖建模时的上传及调整图片。
  before(:each) do
    @uploader = PersonAvatarUploader.new(mock_model(Person).as_null_object)
    @uploader.store!(File.open(path_to_file))
  end

  # 执行完例子时停用图片处理
  after(:all) do
    UserAvatarUploader.enable_processing = false
  end

  # 测试图片是否不比给定的维度长
  context 'the default version' do
    it 'scales down an image to be no larger than 256 by 256 pixels' do
      @uploader.should be_no_larger_than(256, 256)
    end
  end

  # 测试图片是否有确切的维度
  context 'the thumb version' do
    it 'scales down an image to be exactly 64 by 64 pixels' do
      @uploader.thumb.should have_dimensions(64, 64)
    end
  end
end

延伸阅读

有几个绝妙讲述 Rails 风格的资源,若有闲暇时应当考虑延伸阅读:

贡献

在本指南所写的每个东西都不是定案。这只是我渴望想与同样对 Rails 编码风格有兴趣的大家一起工作,以致于最终我们可以替整个 Ruby 社群创造一个有益的资源。

欢迎开票或发送一个带有改进的更新请求。在此提前感谢你的帮助!

口耳相传

一份社群驱动的风格指南,对一个社群来说,只是让人知道有这个社群。微博转发这份指南,分享给你的朋友或同事。我们得到的每个注解、建议或意见都可以让这份指南变得更好一点。而我们想要拥有的是最好的指南,不是吗?