介绍

作者:Bozhidar Batsov

榜样很重要。

 — 警官 Alex J. Murphy / RoboCop

本指南的目的是提供一套针对 Ruby on Rails 开发的最佳实践和风格规范。它是对现有的社区驱动的 Ruby 编码风格指南 的补充指南。

本 Rails 风格指南推荐最佳实践,以便现实世界的 Rails 程序员可以编写可由其他现实世界的 Rails 程序员维护的代码。反映现实世界使用的风格指南会被使用,而坚持一个被使用它的人拒绝的理想的风格指南则有风险根本不会被使用 - 无论它有多好。

本指南分为几个相关规则部分。我尝试添加了规则背后的理由(如果省略了,我假设它很明显)。

我并没有凭空想出所有规则 - 它们主要基于我作为专业软件工程师的丰富职业生涯、来自 Rails 社区成员的反馈和建议以及各种备受推崇的 Rails 编程资源。

注意
这里的一些建议仅适用于最近版本的 Rails。

您可以使用 AsciiDoctor PDF 生成本指南的 PDF 版本,并使用 以下命令 AsciiDoctor 生成 HTML 版本

# Generates README.pdf
asciidoctor-pdf -a allow-uri-read README.adoc

# Generates README.html
asciidoctor README.adoc
提示

安装 rouge gem 以在生成的文档中获得漂亮的语法高亮。

gem install rouge

本指南的翻译提供以下语言版本

提示
RuboCop,一个静态代码分析器(linter)和格式化程序,有一个基于本风格指南的 rubocop-rails 扩展。

配置

配置初始化器

将自定义初始化代码放在 config/initializers 中。初始化器中的代码在应用程序启动时执行。

Gem 初始化器

将每个 gem 的初始化代码保存在一个单独的文件中,文件名与 gem 相同,例如 carrierwave.rbactive_admin.rb 等。

开发/测试/生产配置

相应地调整开发、测试和生产环境的设置(在 config/environments/ 下的相应文件中)。

标记要预编译的额外资产(如果有)。

# config/environments/production.rb
# Precompile additional assets (application.js, application.css,
#and all non-JS/CSS are already added)
config.assets.precompile += %w( rails_admin/rails_admin.css rails_admin/rails_admin.js )

应用程序配置

将适用于所有环境的配置保存在 config/application.rb 文件中。

加载 Rails 配置默认值

升级到较新的 Rails 版本时,应用程序的配置设置将保留在旧版本上。为了利用最新的推荐 Rails 实践,config.load_defaults 设置应与您的 Rails 版本匹配。

# good
config.load_defaults 6.1

类似生产环境的预发布环境

避免创建除 developmenttestproduction 默认值之外的额外环境配置。如果您需要类似生产环境的预发布环境,请使用环境变量进行配置选项。

YAML 配置

将任何额外的配置保存在 config/ 目录下的 YAML 文件中。

自从 Rails 4.2 版本起,YAML 配置文件可以通过新的 `config_for` 方法轻松加载。

Rails::Application.config_for(:yaml_file)

路由

成员集合路由

当您需要为 RESTful 资源添加更多操作时(您真的需要它们吗?),请使用 `member` 和 `collection` 路由。

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

# good
resources :subscriptions do
  get 'unsubscribe', on: :member
end

# bad
get 'photos/search'
resources :photos

# good
resources :photos do
  get 'search', on: :collection
end

多个成员集合路由

如果您需要定义多个 `member/collection` 路由,请使用替代的块语法。

resources :subscriptions do
  member do
    get 'unsubscribe'
    # more routes
  end
end

resources :photos do
  collection do
    get 'search'
    # more routes
  end
end

嵌套路由

使用嵌套路由来更好地表达 Active Record 模型之间的关系。

class Post < ApplicationRecord
  has_many :comments
end

class Comment < ApplicationRecord
  belongs_to :post
end

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

浅层路由

如果您需要嵌套路由超过 1 层,请使用 `shallow: true` 选项。这将为用户节省长 URL(例如 `posts/1/comments/5/versions/7/edit`)以及您节省长 URL 助手(例如 `edit_post_comment_version`)。

resources :posts, shallow: true do
  resources :comments do
    resources :versions
  end
end

命名空间路由

使用命名空间路由来对相关操作进行分组。

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

无通配符路由

切勿使用传统的通配符控制器路由。此路由将使所有控制器中的所有操作都可通过 GET 请求访问。

# very bad
match ':controller(/:action(/:id(.:format)))'

无匹配路由

除非需要使用 `:via` 选项将多个请求类型(例如 `[:get, :post, :patch, :put, :delete]`)映射到单个操作,否则不要使用 `match` 来定义任何路由。

控制器

瘦控制器

保持控制器精简 - 它们应该只检索视图层的数据,而不应该包含任何业务逻辑(所有业务逻辑都应该自然地驻留在模型中)。

单方法

每个控制器操作(理想情况下)应该只调用一个方法,除了初始的查找或新建操作。

共享实例变量

尽量减少在控制器和视图之间传递的实例变量数量。

词法作用域的动作过滤器

在 Action Filter 的选项中指定的控制器操作应在词法范围内。为继承操作指定的 ActionFilter 使其难以理解其对该操作的影响范围。

# bad
class UsersController < ApplicationController
  before_action :require_login, only: :export
end

# good
class UsersController < ApplicationController
  before_action :require_login, only: :export

  def export
  end
end

控制器:渲染

内联渲染

优先使用模板而不是内联渲染。

# very bad
class ProductsController < ApplicationController
  def index
    render inline: "<% products.each do |p| %><p><%= p.name %></p><% end %>", type: :erb
  end
end

# good
## app/views/products/index.html.erb
<%= render partial: 'product', collection: products %>

## app/views/products/_product.html.erb
<p><%= product.name %></p>
<p><%= product.price %></p>

## app/controllers/products_controller.rb
class ProductsController < ApplicationController
  def index
    render :index
  end
end

纯文本渲染

优先使用 render plain: 而不是 render text:

# bad - sets MIME type to `text/html`
...
render text: 'Ruby!'
...

# bad - requires explicit MIME type declaration
...
render text: 'Ruby!', content_type: 'text/plain'
...

# good - short and precise
...
render plain: 'Ruby!'
...

HTTP 状态码符号

优先使用 相应的符号 来代替数字 HTTP 状态码。它们是有意义的,并且对于不太了解的 HTTP 状态码来说,它们不像“魔术”数字。

# bad
...
render status: 403
...

# good
...
render status: :forbidden
...

模型

模型类

随意引入非 Active Record 模型类。

有意义的模型名称

使用有意义的(但简短的)名称来命名模型,不要使用缩写。

非 ActiveRecord 模型

如果您需要支持 ActiveRecord 类行为(如验证)但没有数据库功能的对象,请使用 ActiveModel::Model

class Message
  include ActiveModel::Model

  attr_accessor :name, :email, :content, :priority

  validates :name, presence: true
  validates :email, format: { with: /\A[-a-z0-9_+\.]+\@([-a-z0-9]+\.)+[a-z0-9]{2,4}\z/i }
  validates :content, length: { maximum: 500 }
end

从 Rails 6.1 开始,您还可以使用 ActiveModel::Attributes 扩展来自 ActiveRecord 的属性 API。

class Message
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :name, :string
  attribute :email, :string
  attribute :content, :string
  attribute :priority, :integer

  validates :name, presence: true
  validates :email, format: { with: /\A[-a-z0-9_+\.]+\@([-a-z0-9]+\.)+[a-z0-9]{2,4}\z/i }
  validates :content, length: { maximum: 500 }
end

模型业务逻辑

除非它们在业务领域中具有某种意义,否则不要在模型中放置仅用于格式化数据的函数(如生成 HTML 代码的函数)。这些函数很可能只在视图层中被调用,因此它们应该放在助手函数中。将模型保留用于业务逻辑和数据持久化。

模型:Active Record

保留 Active Record 默认值

除非有充分的理由(例如不受您控制的数据库),否则避免更改 Active Record 默认值(表名、主键等)。

# bad - don't do this if you can modify the schema
class Transaction < ApplicationRecord
  self.table_name = 'order'
  ...
end

始终追加到 ignored_columns

避免设置 ignored_columns。它可能会覆盖之前的赋值,这几乎总是错误的。优先选择追加到列表而不是覆盖。

class Transaction < ApplicationRecord
  # bad - it may overwrite previous assignments
  self.ignored_columns = %i[legacy]

  # good - the value is appended to the list
  self.ignored_columns += %i[legacy]
  ...
end

枚举

优先使用哈希语法来定义 enum。数组会使数据库值隐式,并且在中间插入/删除/重新排列值很可能会导致代码错误。

class Transaction < ApplicationRecord
  # bad - implicit values - ordering matters
  enum type: %i[credit debit]

  # good - explicit values - ordering does not matter
  enum type: {
    credit: 0,
    debit: 1
  }
end

宏风格方法

将宏风格方法(has_manyvalidates 等)分组放在类定义的开头。

class User < ApplicationRecord
  # keep the default scope first (if any)
  default_scope { where(active: true) }

  # constants come up next
  COLORS = %w(red green blue)

  # afterwards we put attr related macros
  attr_accessor :formatted_date_of_birth

  attr_accessible :login, :first_name, :last_name, :email, :password

  # Rails 4+ enums after attr macros
  enum role: { user: 0, moderator: 1, admin: 2 }

  # followed by association macros
  belongs_to :country

  has_many :authentications, dependent: :destroy

  # and validation macros
  validates :email, presence: true
  validates :username, presence: true
  validates :username, uniqueness: { case_sensitive: false }
  validates :username, format: { with: /\A[A-Za-z][A-Za-z0-9._-]{2,19}\z/ }
  validates :password, format: { with: /\A\S{8,128}\z/, allow_nil: true }

  # next we have callbacks
  before_save :cook
  before_save :update_username_lower

  # other macros (like devise's) should be placed after the callbacks

  ...
end

has_many :through

优先使用 has_many :through 而不是 has_and_belongs_to_many。使用 has_many :through 可以在连接模型上添加额外的属性和验证。

# not so good - using has_and_belongs_to_many
class User < ApplicationRecord
  has_and_belongs_to_many :groups
end

class Group < ApplicationRecord
  has_and_belongs_to_many :users
end

# preferred way - using has_many :through
class User < ApplicationRecord
  has_many :memberships
  has_many :groups, through: :memberships
end

class Membership < ApplicationRecord
  belongs_to :user
  belongs_to :group
end

class Group < ApplicationRecord
  has_many :memberships
  has_many :users, through: :memberships
end

读取属性

优先使用 self[:attribute] 而不是 read_attribute(:attribute)

# bad
def amount
  read_attribute(:amount) * 100
end

# good
def amount
  self[:amount] * 100
end

写入属性

优先使用 self[:attribute] = value 而不是 write_attribute(:attribute, value)

# bad
def amount
  write_attribute(:amount, 100)
end

# good
def amount
  self[:amount] = 100
end

新式验证

始终使用 "新式" 验证.

# bad
validates_presence_of :email
validates_length_of :email, maximum: 100

# good
validates :email, presence: true, length: { maximum: 100 }

自定义验证方法

命名自定义验证方法时,请遵循以下简单规则:

  • validate :method_name 就像一个自然的语句

  • 方法名称解释了它检查的内容

  • 该方法通过其名称可以识别为验证方法,而不是谓词方法

# good
validate :expiration_date_cannot_be_in_the_past
validate :discount_cannot_be_greater_than_total_value
validate :ensure_same_topic_is_chosen

# also good - explicit prefix
validate :validate_birthday_in_past
validate :validate_sufficient_quantity
validate :must_have_owner_with_no_other_items
validate :must_have_shipping_units

# bad
validate :birthday_in_past
validate :owner_has_no_other_items

单属性验证

为了使验证易于阅读,请不要在每个验证中列出多个属性。

# bad
validates :email, :password, presence: true
validates :email, length: { maximum: 100 }

# good
validates :email, presence: true, length: { maximum: 100 }
validates :password, presence: true

自定义验证器文件

当自定义验证被使用多次或验证是某种正则表达式映射时,请创建一个自定义验证器文件。

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

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

class Person
  validates :email, email: true
end

应用程序验证器

将自定义验证器放在 app/validators 下。

自定义验证器 Gem

如果您维护着几个相关的应用程序或验证器足够通用,请考虑将自定义验证器提取到一个共享的 Gem 中。

命名范围

随意使用命名范围。

class User < ApplicationRecord
  scope :active, -> { where(active: true) }
  scope :inactive, -> { where(active: false) }

  scope :with_orders, -> { joins(:orders).select('distinct(users.id)') }
end

命名范围类

当使用 lambda 和参数定义的命名范围变得过于复杂时,最好使用类方法来代替,该方法可以实现与命名范围相同的目的,并返回一个 ActiveRecord::Relation 对象。 当然,你可以用这种方式定义更简单的范围。

class User < ApplicationRecord
  def self.with_orders
    joins(:orders).select('distinct(users.id)')
  end
end

回调顺序

按照回调执行的顺序声明回调。 有关参考,请参阅 可用回调

# bad
class Person
  after_commit :after_commit_callback
  before_validation :before_validation_callback
end

# good
class Person
  before_validation :before_validation_callback
  after_commit :after_commit_callback
end

注意跳过模型验证

注意以下方法的行为。 它们不会运行模型验证,并且可能很容易破坏模型状态。

# bad
Article.first.decrement!(:view_count)
DiscussionBoard.decrement_counter(:post_count, 5)
Article.first.increment!(:view_count)
DiscussionBoard.increment_counter(:post_count, 5)
person.toggle :active
product.touch
Billing.update_all("category = 'authorized', author = 'David'")
user.update_attribute(:website, 'example.com')
user.update_columns(last_request_at: Time.current)
Post.update_counters 5, comment_count: -1, action_count: 1

# good
user.update_attributes(website: 'example.com')

用户友好的 URL

使用用户友好的 URL。 在 URL 中显示模型的一些描述性属性,而不是其 id。 有多种方法可以实现这一点。

覆盖模型的 to_param 方法

Rails 使用此方法来构造指向对象的 URL。 默认实现将记录的 id 作为字符串返回。 可以覆盖它以包含另一个人类可读的属性。

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

为了将此转换为用户友好的值,应该在字符串上调用 parameterize。 对象的 id 需要位于开头,以便 Active Record 的 find 方法可以找到它。

friendly_id Gem

它允许通过使用模型的一些描述性属性而不是其 id 来创建人类可读的 URL。

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

查看 gem 文档 以获取有关其用法的更多信息。

find_each

使用 find_each 迭代 AR 对象集合。 从数据库中循环遍历记录集合(例如,使用 all 方法)非常低效,因为它会尝试一次性实例化所有对象。 在这种情况下,批处理方法允许你分批处理记录,从而大大减少内存消耗。

# bad
Person.all.each do |person|
  person.do_awesome_stuff
end

Person.where('age > 21').each do |person|
  person.party_all_night!
end

# good
Person.find_each do |person|
  person.do_awesome_stuff
end

Person.where('age > 21').find_each do |person|
  person.party_all_night!
end

before_destroy

由于 Rails 为依赖关联创建回调,因此始终使用 prepend: true 调用执行验证的 before_destroy 回调。

# bad (roles will be deleted automatically even if super_admin? is true)
has_many :roles, dependent: :destroy

before_destroy :ensure_deletable

def ensure_deletable
  raise "Cannot delete super admin." if super_admin?
end

# good
has_many :roles, dependent: :destroy

before_destroy :ensure_deletable, prepend: true

def ensure_deletable
  raise "Cannot delete super admin." if super_admin?
end

has_many/has_one 依赖选项

has_manyhas_one 关联定义 dependent 选项。

# bad
class Post < ApplicationRecord
  has_many :comments
end

# good
class Post < ApplicationRecord
  has_many :comments, dependent: :destroy
end

save!

在持久化 AR 对象时,始终使用抛出异常的 bang! 方法或处理方法返回值。这适用于 createsaveupdatedestroyfirst_or_createfind_or_create_by

# bad
user.create(name: 'Bruce')

# bad
user.save

# good
user.create!(name: 'Bruce')
# or
bruce = user.create(name: 'Bruce')
if bruce.persisted?
  ...
else
  ...
end

# good
user.save!
# or
if user.save
  ...
else
  ...
end

模型:Active Record 查询

避免插值

避免在查询中使用字符串插值,因为它会使您的代码容易受到 SQL 注入攻击。

# bad - param will be interpolated unescaped
Client.where("orders_count = #{params[:orders]}")

# good - param will be properly escaped
Client.where('orders_count = ?', params[:orders])

命名占位符

当您的查询中有多个占位符时,请考虑使用命名占位符而不是位置占位符。

# okish
Client.where(
  'orders_count >= ? AND country_code = ?',
  params[:min_orders_count], params[:country_code]
)

# good
Client.where(
  'orders_count >= :min_orders_count AND country_code = :country_code',
  min_orders_count: params[:min_orders_count], country_code: params[:country_code]
)

find

当您需要通过主键 ID 检索单个记录并在记录未找到时引发 ActiveRecord::RecordNotFound 时,请优先使用 find 而不是 where.take!find_by!find_by_id!

# bad
User.where(id: id).take!

# bad
User.find_by_id!(id)

# bad
User.find_by!(id: id)

# good
User.find(id)

find_by

当您需要通过一个或多个属性检索单个记录并在记录未找到时返回 nil 时,请优先使用 find_by 而不是 where.takefind_by_attribute

# bad
User.where(email: email).take
User.where(first_name: 'Bruce', last_name: 'Wayne').take

# bad
User.find_by_email(email)
User.find_by_first_name_and_last_name('Bruce', 'Wayne')

# good
User.find_by(email: email)
User.find_by(first_name: 'Bruce', last_name: 'Wayne')

哈希条件

优先将条件作为哈希传递给 wherewhere.not,而不是使用 SQL 片段。

# bad
User.where("name = ?", name)

# good
User.where(name: name)

# bad
User.where("id != ?", id)

# good
User.where.not(id: id)

查找缺少的关系记录

如果您使用的是 Rails 6.1 或更高版本,请使用 where.missing 来查找缺少的关系记录。

# bad
Post.left_joins(:author).where(authors: { id: nil })

# good
Post.where.missing(:author)

id 排序

不要使用 id 列进行排序。尽管 ID 的顺序通常(偶然地)是按时间顺序排列的,但不能保证 ID 的顺序是按任何特定顺序排列的。使用时间戳列按时间顺序排序。作为奖励,意图更清晰。

# bad
scope :chronological, -> { order(id: :asc) }

# good
scope :chronological, -> { order(created_at: :asc) }

pluck

使用 pluck 从多个记录中选择单个值。

# bad
User.all.map(&:name)

# bad
User.all.map { |user| user[:name] }

# good
User.pluck(:name)

pick

使用 pick 从单个记录中选择单个值。

# bad
User.pluck(:name).first

# bad
User.first.name

# good
User.pick(:name)

ids

优先使用 ids 而不是 pluck(:id)

# bad
User.pluck(:id)

# good
User.ids

压缩的 Heredocs

find_by_sql 等方法中指定显式查询时,请使用带有 squish 的 heredocs。这允许您使用换行符和缩进格式化 SQL,同时支持许多工具(包括 GitHub、Atom 和 RubyMine)中的语法高亮显示。

User.find_by_sql(<<-SQL.squish)
  SELECT
    users.id, accounts.plan
  FROM
    users
  INNER JOIN
    accounts
  ON
    accounts.user_id = users.id
  # further complexities...
SQL

String#squish 会删除缩进和换行符,以便您的服务器日志显示流畅的 SQL 字符串,而不是类似这样的内容

SELECT\n    users.id, accounts.plan\n  FROM\n    users\n  INNER JOIN\n    accounts\n  ON\n    accounts.user_id = users.id

size 优于 countlength

在查询 Active Record 集合时,优先使用 size(根据集合是否已加载选择 count/length 行为)或 length(始终加载整个集合并计算数组元素)而不是 count(始终执行数据库查询以获取计数)。

# bad
User.count

# good
User.all.size

# good - if you really need to load all users into memory
User.all.length

使用范围

使用范围而不是使用模板为标量值定义比较条件。

# bad
User.where("created_at >= ?", 30.days.ago).where("created_at <= ?", 7.days.ago)
User.where("created_at >= ? AND created_at <= ?", 30.days.ago, 7.days.ago)
User.where("created_at >= :start AND created_at <= :end", start: 30.days.ago, end: 7.days.ago)

# good
User.where(created_at: 30.days.ago..7.days.ago)

# bad
User.where("created_at >= ?", 7.days.ago)

# good
User.where(created_at: 7.days.ago..)

# note - ranges are inclusive or exclusive of their ending, not beginning
User.where(created_at: 7.days.ago..)  # produces >=
User.where(created_at: 7.days.ago...) # also produces >=
User.where(created_at: ..7.days.ago)  # inclusive: produces <=
User.where(created_at: ...7.days.ago) # exclusive: produces <

# okish - there is no range syntax that would denote exclusion at the beginning of the range
Customer.where("purchases_count > :min AND purchases_count <= :max", min: 0, max: 5)
注意
Rails 6.0 或更高版本需要无穷范围 Ruby 2.6 语法,Rails 6.0.3 需要无始范围 Ruby 2.7 语法。

where.not 使用多个属性

避免将多个属性传递给 where.not。在这种情况下,Rails 逻辑在 Rails 6.1 中发生了变化,现在将产生匹配其中任何一个条件的结果,例如 where.not(status: 'active', plan: 'basic') 将在计划为企业时返回具有活动状态的记录。

# bad
User.where.not(status: 'active', plan: 'basic')

# good
User.where.not('status = ? AND plan = ?', 'active', 'basic')

冗余的 all

使用 all 作为接收器是冗余的。在没有 all 的情况下,结果不会改变,因此应该删除它。

# bad
User.all.find(id)
User.all.order(:created_at)
users.all.where(id: ids)
user.articles.all.order(:created_at)

# good
User.find(id)
User.order(:created_at)
users.where(id: ids)
user.articles.order(:created_at)
注意
all 的接收器是关联时,有些方法的行为会因省略 all 而改变。

以下方法在没有 all 的情况下行为不同

因此,在考虑从这些方法的接收者中删除all时,建议参考文档以了解行为的变化。

迁移

模式版本

schema.rb(或structure.sql)置于版本控制之下。

数据库模式加载

使用rake db:schema:load而不是rake db:migrate来初始化空数据库。

默认迁移值

在迁移本身中强制执行默认值,而不是在应用程序层。

# bad - application enforced default value
class Product < ApplicationRecord
  def amount
    self[:amount] || 0
  end
end

# good - database enforced
class AddDefaultAmountToProducts < ActiveRecord::Migration
  def change
    change_column_default :products, :amount, 0
  end
end

虽然许多 Rails 开发人员建议仅在 Rails 中强制执行表默认值,但这是一种非常脆弱的方法,会使您的数据容易受到许多应用程序错误的影响。您还需要考虑大多数非平凡的应用程序与其他应用程序共享数据库,因此从 Rails 应用程序强制执行数据完整性是不可能的。

三态布尔值

对于 SQL 数据库,如果布尔列没有指定默认值,它将具有三个可能的值:truefalseNULL。布尔运算符以意想不到的方式工作NULL

例如,在 SQL 查询中,true AND NULLNULL(而不是 false),true AND NULL OR falseNULL(而不是 false)。这会导致 SQL 查询返回意外的结果。

为了避免这种情况,布尔列应始终具有默认值和 NOT NULL 约束。

# bad - boolean without a default value
add_column :users, :active, :boolean

# good - boolean with a default value (`false` or `true`) and with restricted `NULL`
add_column :users, :active, :boolean, default: true, null: false
add_column :users, :admin, :boolean, default: false, null: false

外键约束

强制执行外键约束。从 Rails 4.2 开始,Active Record 本地支持外键约束。

# bad - does not add foreign keys
create_table :comment do |t|
  t.references :article
  t.belongs_to :user
  t.integer :category_id
end

# good
create_table :comment do |t|
  t.references :article, foreign_key: true
  t.belongs_to :user, foreign_key: true
  t.references :category, foreign_key: { to_table: :comment_categories }
end

更改与上/下

在编写构造性迁移(添加表或列)时,使用change方法而不是updown方法。

# the old way
class AddNameToPeople < ActiveRecord::Migration
  def up
    add_column :people, :name, :string
  end

  def down
    remove_column :people, :name
  end
end

# the new preferred way
class AddNameToPeople < ActiveRecord::Migration
  def change
    add_column :people, :name, :string
  end
end

定义模型类迁移

如果您必须在迁移中使用模型,请确保定义它们,以避免将来出现损坏的迁移。

# db/migrate/<migration_file_name>.rb
# frozen_string_literal: true

# bad
class ModifyDefaultStatusForProducts < ActiveRecord::Migration
  def change
    old_status = 'pending_manual_approval'
    new_status = 'pending_approval'

    reversible do |dir|
      dir.up do
        Product.where(status: old_status).update_all(status: new_status)
        change_column :products, :status, :string, default: new_status
      end

      dir.down do
        Product.where(status: new_status).update_all(status: old_status)
        change_column :products, :status, :string, default: old_status
      end
    end
  end
end

# good
# Define `table_name` in a custom named class to make sure that you run on the
# same table you had during the creation of the migration.
# In future if you override the `Product` class and change the `table_name`,
# it won't break the migration or cause serious data corruption.
class MigrationProduct < ActiveRecord::Base
  self.table_name = :products
end

class ModifyDefaultStatusForProducts < ActiveRecord::Migration
  def change
    old_status = 'pending_manual_approval'
    new_status = 'pending_approval'

    reversible do |dir|
      dir.up do
        MigrationProduct.where(status: old_status).update_all(status: new_status)
        change_column :products, :status, :string, default: new_status
      end

      dir.down do
        MigrationProduct.where(status: new_status).update_all(status: old_status)
        change_column :products, :status, :string, default: old_status
      end
    end
  end
end

有意义的外键命名

显式命名外键,而不是依赖 Rails 自动生成的 FK 名称。(https://guides.rubyonrails.net.cn/active_record_migrations.html#foreign-keys

# bad
class AddFkArticlesToAuthors < ActiveRecord::Migration
  def change
    add_foreign_key :articles, :authors
  end
end

# good
class AddFkArticlesToAuthors < ActiveRecord::Migration
  def change
    add_foreign_key :articles, :authors, name: :articles_author_id_fk
  end
end

可逆迁移

不要在 change 方法中使用不可逆的迁移命令。可逆的迁移命令列在下面。 ActiveRecord::Migration::CommandRecorder

# bad
class DropUsers < ActiveRecord::Migration
  def change
    drop_table :users
  end
end

# good
class DropUsers < ActiveRecord::Migration
  def up
    drop_table :users
  end

  def down
    create_table :users do |t|
      t.string :name
    end
  end
end

# good
# In this case, block will be used by create_table in rollback
# https://api.rubyonrails.net.cn/classes/ActiveRecord/ConnectionAdapters.html#method-i-drop_table
class DropUsers < ActiveRecord::Migration
  def change
    drop_table :users do |t|
      t.string :name
    end
  end
end

视图

不要直接在视图中调用模型

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

不要在视图中进行复杂的格式化

避免在视图中进行复杂的格式化。视图助手对于简单的情况很有用,但如果更复杂,则考虑使用装饰器或演示器。

局部模板

通过使用局部模板和布局来减少代码重复。

局部模板中不要使用实例变量

避免在局部模板中使用实例变量,而是将局部变量传递给 render。局部模板可能在不同的控制器或操作中使用,其中变量可能具有不同的名称,甚至可能不存在。在这种情况下,未定义的实例变量不会引发异常,而局部变量会引发异常。

<!-- bad -->
<!-- app/views/courses/show.html.erb -->
<%= render 'course_description' %>
<!-- app/views/courses/_course_description.html.erb -->
<%= @course.description %>

<!-- good -->
<!-- app/views/courses/show.html.erb -->
<%= render 'course_description', course: @course %>
<!-- app/views/courses/_course_description.html.erb -->
<%= course.description %>

国际化

本地化文本

在视图、模型和控制器中不要使用字符串或其他特定于区域设置的设置。这些文本应移至 config/locales 目录中的区域设置文件。

翻译后的标签

当 Active Record 模型的标签需要翻译时,使用 activerecord 范围

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

然后 User.model_name.human 将返回 "Member",而 User.human_attribute_name("name") 将返回 "Full name"。这些属性的翻译将用作视图中的标签。

组织区域设置文件

将视图中使用的文本与 Active Record 属性的翻译分开。将模型的区域设置文件放在 locales/models 文件夹中,将视图中使用的文本放在 locales/views 文件夹中。

当使用额外的目录组织区域设置文件时,必须在application.rb文件中描述这些目录才能加载它们。

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

共享本地化

将共享的本地化选项(例如日期或货币格式)放在locales目录根目录下的文件中。

简短 I18n

使用 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 中这样查找

# bad
= t 'users.show.title'

# good
= t '.title'

点分隔键

使用点分隔的区域设置键,而不是使用数组或单个符号指定:scope选项。点分隔表示法更易于阅读和跟踪层次结构。

# bad
I18n.t :record_invalid, scope: [:activerecord, :errors, :messages]

# good
I18n.t :record_invalid, scope: 'activerecord.errors.messages'
I18n.t 'activerecord.errors.messages.record_invalid'

# bad
I18n.t :title, scope: :invitation

# good
I18n.t 'title.invitation'

I18n 指南

有关 Rails I18n 的更多详细信息,请参阅Rails 指南

资产

使用资产管道来利用应用程序中的组织结构。

保留 app/assets

保留 app/assets 用于自定义样式表、javascript 或图像。

lib/assets

lib/assets 用于不完全属于应用程序范围的库。

vendor/assets

第三方代码(例如jQuerybootstrap)应放在 vendor/assets 中。

gem/assets

尽可能使用资产的 gem 化版本(例如jquery-railsjquery-ui-railsbootstrap-sasszurb-foundation)。

邮件程序

邮件程序名称

将邮件程序命名为 SomethingMailer。如果没有 Mailer 后缀,则无法立即清楚地知道什么是邮件程序以及哪些视图与邮件程序相关。

HTML 纯文本电子邮件

提供 HTML 和纯文本视图模板。

启用邮件传递错误

在开发环境中启用邮件传递失败时引发的错误。默认情况下,这些错误被禁用。

# config/environments/development.rb

config.action_mailer.raise_delivery_errors = true

本地 SMTP

在开发环境中使用本地 SMTP 服务器,例如 Mailcatcher

# config/environments/development.rb

config.action_mailer.smtp_settings = {
  address: 'localhost',
  port: 1025,
  # more settings
}

默认主机名

提供主机名的默认设置。

# 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' }

# in your mailer class
default_url_options[:host] = 'your_site.com'

电子邮件地址

正确格式化发件人和收件人地址。使用以下格式

# in your mailer class
default from: 'Your Name <info@your_site.com>'

如果您使用的是 Rails 6.1 或更高版本,可以使用 email_address_with_name 方法

# in your mailer class
default from: email_address_with_name('info@your_site.com', 'Your Name')

传递方法测试

确保测试环境的电子邮件传递方法设置为 test

# config/environments/test.rb

config.action_mailer.delivery_method = :test

传递方法 SMTP

开发和生产环境的传递方法应为 smtp

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

config.action_mailer.delivery_method = :smtp

内联电子邮件样式

发送 HTML 电子邮件时,所有样式都应内联,因为某些邮件客户端无法处理外部样式。但是,这会使它们更难维护并导致代码重复。有两个类似的 gem 可以转换样式并将它们放在相应的 HTML 标签中:premailer-railsroadie

后台电子邮件

应避免在生成页面响应时发送电子邮件。这会导致页面加载延迟,如果发送了多个电子邮件,请求可能会超时。为了克服这个问题,可以使用 sidekiq gem 在后台进程中发送电子邮件。

Active Support 核心扩展

try!

优先使用 Ruby 2.3 的安全导航运算符 &. 而不是 ActiveSupport#try!

# bad
obj.try! :fly

# good
obj&.fly

Active Support 别名

优先使用 Ruby 的标准库方法而不是 ActiveSupport 别名。

# bad
'the day'.starts_with? 'th'
'the day'.ends_with? 'ay'

# good
'the day'.start_with? 'th'
'the day'.end_with? 'ay'

Active Support 扩展

优先使用 Ruby 的标准库而不是不常用的 Active Support 扩展。

# bad
(1..50).to_a.forty_two
1.in? [1, 2]
'day'.in? 'the day'

# good
(1..50).to_a[41]
[1, 2].include? 1
'the day'.include? 'day'

inquiry

优先使用 Ruby 的比较运算符而不是 Active Support 的 Array#inquiryString#inquiry

# bad - String#inquiry
ruby = 'two'.inquiry
ruby.two?

# good
ruby = 'two'
ruby == 'two'

# bad - Array#inquiry
pets = %w(cat dog).inquiry
pets.gopher?

# good
pets = %w(cat dog)
pets.include? 'cat'

exclude?

优先使用 Active Support 的 exclude? 而不是 Ruby 的否定 include?

# bad
!array.include?(2)
!hash.include?(:key)
!string.include?('substring')

# good
array.exclude?(2)
hash.exclude?(:key)
string.exclude?('substring')

优先使用波浪号 heredoc 而不是 strip_heredoc

如果您使用的是 Ruby 2.3 或更高版本,优先使用波浪号 heredoc (<<~) 而不是 Active Support 的 strip_heredoc

# bad
<<EOS.strip_heredoc
  some text
EOS

# bad
<<-EOS.strip_heredoc
  some text
EOS

# good
<<~EOS
  some text
EOS

优先使用 to_fs 用于格式化字符串

如果您使用的是 Rails 7.0 或更高版本,优先使用 to_fs 而不是 to_formatted_sto_formatted_s 对一个经常使用的函数来说太繁琐了。

# bad
time.to_formatted_s(:db)
date.to_formatted_s(:db)
datetime.to_formatted_s(:db)
42.to_formatted_s(:human)

# good
time.to_fs(:db)
date.to_fs(:db)
datetime.to_fs(:db)
42.to_fs(:human)

时间

时区配置

application.rb 中相应地配置您的时区。

config.time_zone = 'Eastern European Time'
# optional - note it can be only :utc or :local (default is :utc)
config.active_record.default_timezone = :local

Time.parse

不要使用 Time.parse

# bad
Time.parse('2015-03-02 19:05:37') # => Will assume time string given is in the system's time zone.

# good
Time.zone.parse('2015-03-02 19:05:37') # => Mon, 02 Mar 2015 19:05:37 EET +02:00

to_time

不要使用 String#to_time

# bad - assumes time string given is in the system's time zone.
'2015-03-02 19:05:37'.to_time

# good
Time.zone.parse('2015-03-02 19:05:37') # => Mon, 02 Mar 2015 19:05:37 EET +02:00

Time.now

不要使用 Time.now

# bad
Time.now # => Returns system time and ignores your configured time zone.

# good
Time.zone.now # => Fri, 12 Mar 2014 22:04:47 EET +02:00
Time.current # Same thing but shorter.

优先使用 all_(day|week|month|quarter|year) 而不是日期/时间的范围

优先使用 all_(day|week|month|quarter|year) 而不是 beginning_of_(day|week|month|quarter|year)..end_of_(day|week|month|quarter|year) 来获取日期/时间的范围。

# bad
date.beginning_of_day..date.end_of_day
date.beginning_of_week..date.end_of_week
date.beginning_of_month..date.end_of_month
date.beginning_of_quarter..date.end_of_quarter
date.beginning_of_year..date.end_of_year

# good
date.all_day
date.all_week
date.all_month
date.all_quarter
date.all_year

持续时间

持续时间应用

如果未使用参数,优先使用 from_nowago 而不是 sinceafteruntilbefore

# bad - It's not clear that the qualifier refers to the current time (which is the default parameter)
5.hours.since
5.hours.after
5.hours.before
5.hours.until

# good
5.hours.from_now
5.hours.ago

如果使用参数,优先使用 sinceafteruntilbefore 而不是 from_nowago

# bad - It's confusing and misleading to read
2.days.from_now(yesterday)
2.days.ago(yesterday)

# good
2.days.since(yesterday)
2.days.after(yesterday)
2.days.before(yesterday)
2.days.until(yesterday)

避免对持续时间主题使用负数。始终优先使用允许使用正字面量的限定词。

# bad - It's confusing and misleading to read
-5.hours.from_now
-5.hours.ago

# good
5.hours.ago
5.hours.from_now

持续时间算术

使用持续时间方法而不是与当前时间加减。

# bad
Time.current - 1.minute
Time.zone.now + 2.days

# good
1.minute.ago
2.days.from_now

Bundler

开发/测试宝石

将仅用于开发或测试的 gem 放入 Gemfile 中的适当组。

仅使用优质 gem

在您的项目中仅使用成熟的 gem。如果您正在考虑包含一些鲜为人知的 gem,您应该首先仔细审查其源代码。

Gemfile.lock

不要从版本控制中删除 Gemfile.lock。这不是一些随机生成的的文件 - 它确保所有团队成员在执行 bundle install 时获得相同的 gem 版本。

测试

集成测试

优先使用集成风格的控制器测试而不是功能风格的控制器测试,如 Rails 文档中所建议的那样

# bad
class MyControllerTest < ActionController::TestCase
end

# good
class MyControllerTest < ActionDispatch::IntegrationTest
end

freeze_time

优先使用 ActiveSupport::Testing::TimeHelpers#freeze_time 而不是 ActiveSupport::Testing::TimeHelpers#travel_to,并使用当前时间作为参数。

# bad
travel_to(Time.now)
travel_to(DateTime.now)
travel_to(Time.current)
travel_to(Time.zone.now)
travel_to(Time.now.in_time_zone)
travel_to(Time.current.to_time)

# good
freeze_time

管理进程

Foreman

如果您的项目依赖于各种外部进程,请使用 foreman 来管理它们。

进一步阅读

贡献

本指南中所写的内容并非一成不变。我希望与所有对 Rails 编码风格感兴趣的人一起合作,以便我们最终能够创建一个对整个 Ruby 社区都有益的资源。

欢迎您提出问题或发送包含改进的拉取请求。感谢您的帮助!

您也可以通过 Patreon 为项目(以及 RuboCop)提供经济支持。

如何贡献?

很简单,只需遵循以下贡献指南

  • Fork GitHub 上的 项目

  • 在功能分支中进行功能添加或错误修复。

  • 包含对更改的 良好描述

  • 将您的功能分支推送到 GitHub

  • 发送 Pull Request

许可证

传播消息

社区驱动的风格指南对于不知道其存在的社区来说毫无用处。在 Twitter 上发布指南,与您的朋友和同事分享。我们收到的每条评论、建议或意见都会使指南更上一层楼。我们想要拥有最好的指南,不是吗?

干杯!
Bozhidar