Ruby on Rails 是一款被宽泛使用的 Web 应用程序框架。 Rails 使我们办公更有效率,让我们更专注于手头的任务而不是技术本身。在初学阶段,坚持 Rails 的最佳实践非常重要 。因此,在这篇文章中,我们将对 Ruby on Rails 中的最佳实践做系列介绍。
毁灭之路
如果你忽略了 Web
应用程序框架的最佳实践的重要性,那么你就不算了解框架。最坏的情况,你在开发应用程序的过程中会遇到许多问题,你需要不断地进行处理。并且,对最佳实践
缺乏了解,还会给新功能的开发,项目的维护以及开发者的引入带来困难。因此,为保持工作的高能和高效,避免你(或你的团队)在问题出现时焦头烂额,抓耳挠
腮,你应当对这项内容有所了解。
正如标题所示:最佳实践,它们因为某些原因被广泛使用着,以下便列出了几点使用这些实践的好处:
1.可维护性 2.可读性 3.优雅 4.快速发展 5.DRY 代码 让我们一起来了解下。 Ruby on Rails 社区风格指南:每种编程语言都同时包含有精彩的代码和糟糕的代码。代码风格也因人而异,所以,当有新开发成员加入到项目时,需要对代码风格进行一段时间的适应,这就会导致项目的完成时间延迟。因此,拥有一份由社区汇总的编码风格指南就显得尤为重要。因为它能有效地统一代码风格,这有利于代码库的管理。项目的建立通常先由小团队接手,再换到大团队改进,编码风格和背景也就随着开发团队的改变而改变。遵循 Ruby 社区风格指南是第一个我要介绍的最佳实践,这里有几项非常好的风格,我强调一下: 两个空格缩进这是 Ruby 社区中最被广泛采用和最被支持的风格之一。使用 2 空格缩进代替 4 空格缩进。让我们看一个例子: 4 个空格缩进
1 2 3 4 5 6 7 8 | def some_method
some_var = true
if some_var
do_something
else
do_something_else
end
end
|
2 个空格缩进
1 2 3 4 5 6 7 8 | def some_method
some_var = true
if some_var
do_something
else
do_something_else
end
end
|
后者更加简洁明了,也更具可读性。此外,在含有多级别缩进的大文件中,2 格缩进带来的效果更加明显。 用 a 来定义判断方法?在 Ruby 中有一些用以返回 true 或者 false 约定的方法。这些方法就是判断方法,约定是以带有问号(?)的名称结尾。在大多数编程语言中,你能看到各种各样的定义方法或变量名称,如 is_valid 和 is_paid 等。但 Ruby 并不鼓励这种风格,它们希望能使用更易理解的语言来命名,如:object.valid?或 orfee.paid?(注意,这里没有is_ 前缀),这种风格是 Ruby 通用性和可读性的体现。 迭代: 使用 each 而不是 for绝大多数 Ruby 程序员在迭代集合时都是使用 each,而不是 for。因为 each 更简单易读。 * for * * each * 1 2 3 | ( 1 .. 100 ).each do |i|
...
end
|
看到效果了吗? 条件:使用 unless 而不是!if:当你发现自己在使用 if 语句进行条件判断,如: 或者 1 2 3 | if name != "sarmad"
do_that
end
|
你应该立马改用 Ruby 独有的 unless 语句,如: 或者 1 2 3 | unless name == "sarmad"
do_that
end
|
这同样和易读性有关。但需要注意一点,当你的条件中需要使用到 else 语句时,千万不要用 unless-else。 错误的语句 1 2 3 4 5 | unless user.save
# throw error
else
# return success
end
|
正确的语句 1 2 3 4 5 | if user.save
# return success
else
# throw error
end
|
条件判断捷径**
条件判断捷径是一种术语,用于在某些条件下提前退出方法,参考如下示例:
1 2 3 4 5 6 7 | if user.gender == "male" && user.age > 17
do_something
elsif user.gender == "male" && user.age < 17 && user.age > 5
do_something_else
elsif user.age < 5
raise StandardError
end
|
在这种情况下,它需要根据所有条件的来判断用户是否低于5岁,然后抛出异常。首选方法: 1 2 3 4 5 6 | raise StandardError if user.age < 5
if user.gender == "male" && user.age > 17
do_something
elsif user.gender == "male" && user.age < 17 #we saved a redundant check here
do_something_else
end
|
当满足某个条件时,尽早的返回能让程序效率更高。
提示:我强烈建议你仔细阅读这些编码风格指南这里(Ruby)和这里 (Rails)。 编写测试如果你熟悉 Rails 的话,你就会知道 Rails 社区对测试有多么重视。我曾听人说过,对于新手,测试工作是学习 Rails 的一个拦路虎。不过也有人说,从一开始就这样做,有助于掌握 Rails 的基础知识(以及在某些普通的网页开发场景中)。不过,所有这些都不会阻止测试成为软件开发界实至名归的最佳实践。事实上,我也听到过抱怨的声音:如果在完成一个功能的基础上,还要加上测试工作,这就需要花费大量的时间。但是一旦他们进入到 Rails 开发的测试环节,并且一开始就承受了编写测试的“麻烦”,那么在实际构建功能时,立马就能构建好。另外,这样做也能使程序涵盖到许多的边缘场景,然后给项目带来更好的设计。不得不说,一个好的 Ruby 程序员天生就善于做测试。 让我们来列举下测试的一些好处: DRY (不要浪费时间)
要尽可能的确保你没有在浪费时间做重复的工作。 让我们来讨论一下 Ruby 的面向对象规则中避免重复的方法。 使用抽象类: 假设你有下面这样两个类: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | class Mercedes
def accelerate
"60MPH in 5 seconds"
end
def apply_brakes
"stopped in 4 seconds"
end
def open_boot
"opened"
end
def turn_headlights_on
"turned on"
end
def turn_headlights_off
"turned off"
end
end
class Audi
def accelerate
"60MPH in 6.5 seconds"
end
def apply_brakes
"stopped in 3.5 seconds"
end
def open_boot
"opened"
end
def turn_headlights_on
"turned on"
end
def turn_headlights_off
"turned off"
end
end
|
这两个类彼此中有三个重复的方法 open_boot, turn_headlights_on, 以及 turn_headlights_off。在此我们不讨论为什么不要写重复的代码,如果你想了解,你可以读一读这个。现在我们只就 DRY 这个原则进行讨论。这里要使用的最佳实践就是使用继承和/或者抽象类。下面我们来重写这个类以解决问题: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | class Car
# Uncomment the line below if you want this class to be uninstantiable
# i.e you can't make an instance of this class .
# You can only inherit other classes from it.
# self. abstract = true
def open_boot
"opened"
end
def turn_headlights_on
"turned on"
end
def turn_headlights_off
"turned off"
end
end
class Mercedes < Car
def accelerate
"60MPH in 5 seconds"
end
def apply_brakes
"stopped in 4 seconds"
end
end
class Audi < Car
def accelerate
"60MPH in 6.5 seconds"
end
def apply_brakes
"stopped in 3.5 seconds"
end
end
|
看出差异了吗? 这样好多了! 使用模块 模块,从另外一方面来看,是一种在类之间共享行为的灵活方式。(对于人们应该使用其他类模块,而不是继承类(组件)的原因,我们在此也不做讨论。)这里我只说明一下模块是类和行为之间一种“has-a”关系而继承是一种“is-a”关系: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | class Newspaper
def headline
#code
end
def sports_news
#code
end
def world_news
#code
end
def price
#code
end
end
class Book
def title
#code
end
def read_page(page_number)
#code
end
def price
#code
end
def total_pages
#code
end
end
|
假设我们需要向两个类都添加一个 print 方法,而又不想重复编写代码,就可以使用模块,像下面这样: 1 2 3 4 5 | module Printable
def print
#code
end
end
|
修改类的代码,让它们引入这个模块: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | class Newspaper
#This wil add the module's methods as instance methods to this class
include Printable
def headline
#code
end
def sports_news
#code
end
def world_news
#code
end
def price
#code
end
end
class Book
#This wil add the module's methods as instance methods to this class
include Printable
def title
#code
end
def read_page(page_number)
#code
end
def price
#code
end
def total_pages
#code
end
end
|
这是一种非常强大且实用的技术。我们也可以使用 extend Printable 而不是 include Printable 来让模块的方法成为类的方法。 枚举类型的巧妙使用
假如说你有了一个叫做 Book 的模型,这个模型拥有一个列/域,你想要在这个列/域里面存储这本书,而不管是草稿、完成还是发布的状态。你发现自己正要做的会是像下面这样: 1 2 3 4 5 6 7 | if book.status == "draft"
do_something
elsif book.status == "completed"
do_something
elsif book.status == "published"
do_something
end
|
或者: 1 2 3 4 5 6 7 | if book.status == 0 #draft
do_something
elsif book.status == 1 #completed
do_something
elsif book.status == 2 #published
do_something
end
|
如果是这种情况的话,你应该看看枚举类型。你要将这个状态列定义成整型,理想情况下不能为空(null:false), 并且这个模型再创建了之后还需要其状态有一个默认值,例如,默认为 0。现在,你就可以像下面这样定义枚举了: 1 | enum status: { draft: 0 , completed: 1 , published: 2 }
|
现在,你可以将代码进行重写,如下: 1 2 3 4 5 6 7 | if book.draft?
do_something
elsif book.completed?
do_something
elsif book.published?
do_something
end
|
看起来很棒不是吗? 这样的做法不仅让你得到了对应状态名称的判断方法,还给你提供了可以在你所定义的状态之间进行切换的方法。 1 2 3 | book.draft!
book.completed!
book.published!
|
这些方法也可以切换状态来匹配。看,你的工具库新增了一个多么优雅的工具啊。 胖的模型,瘦的控制器和关注点另外一个最佳实践就是,坚持不对控制器之外的相关逻辑进行响应。那些你不想将其放到一个控制器的代码的示例,应该就业务的逻辑或持久化/模型变更的逻辑。例如,有些人可能会让他们的模型看起来像下面这个样子: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | class BooksController < ApplicationController
before_action :set_book, only: [:show, :edit, :update, :destroy, :publish]
# code omitted for brevity
def publish
@book .published = true
pub_date = params[:publish_date]
if pub_date
@book .published_at = pub_date
else
@book .published_at = Time.zone.now
end
if @book .save
# success response, some redirect with a flash notice
else
# failure response, some redirect with a flash alert
end
end
# code omitted for brevity
private
# Use callbacks to share common setup or constraints between actions.
def set_book
@book = Book.find(params[:id])
end
# code omitted for brevity
end
|
现在让我们将这段复杂的逻辑转换成相关的模型: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | class Book < ActiveRecord::Base
def publish(publish_date)
self.published = true
if publish_date
self.published_at = publish_date
else
self.published_at = Time.zone.now
end
save
end
end
class BooksController < ApplicationController
before_action :set_book, only: [:show, :edit, :update, :destroy, :publish]
# code omitted for brevity
def publish
pub_date = params[:publish_date]
if @book .publish(pub_date)
# success response, some redirect with a flash notice
else
# failure response, some redirect with a flash alert
end
end
# code omitted for brevity
private
# Use callbacks to share common setup or constraints between actions.
def set_book
@book = Book.find(params[:id])
end
# code omitted for brevity
end
|
这是一种直观的场景,其中能很明确的认识到这块功能属于模型。而在许多其它的场景中,你就得多花点心思找到一个正确的平衡了,而且要知道什么该怎么放。有时候你从一个控制器那里独立出来的逻辑并不适合放到任何模型的上下文中,这个你就得仔细琢磨一下那里会最适合它了。我会根据我的经验为你设定一些简单的规则,但如果你能想到一些针对一些问题的更好的方法,就在评论中告诉我吧。 控制器应该只是做一些针对模型的简单的查询操作。复杂的查询应该挪到模型中去,并且要在可复用的范围进行分割。控制器中包含的应该主要是一些请求处理和响应相关的逻辑。 任何代码,只要不是跟请求和响应相关的,但是又直接跟一个模型相关,就应该被挪到模型中去。 任何表示一个数据结构的类都应该作为一个 Non-ActiveRecord 模型(无表类)挪到 app/models 目录中去。 当逻辑涉及特定领域(打印,库一起其它诸如此类),而非真正适合一个模型(ActiveRecord or Non-ActiveRecord)的上下文时,就使用 Ruby 的 PORO (Plain Old Ruby Objects) 类。 你可以将这些类放到 app/models/some_directory/ 中去。任何被放到 app/ 目录中的东西都会被自动在应用启动时加载,因为这个目录已经被包含在 Rails 的自动加载路径中了。PORO 也可以被放到 app/models/concerns 以及 app/controllers/concerns 目录中去。 如果是独立于应用程序的 PORO, 模块或者类,可以放到 lib/ 目录中去,这样也可以被用于其它应用程序。 如果你必须从其它不相关的功能中提取常用的功能,请使用模块。你可以将它们放到 app/* 目录,而如果它们是独立于应用程序的,就放到 lib/ 目录。 当应用程序代码不断增长,而难以决定将特殊的逻辑放置到哪里时,“服务”层是另外一个支持通用MVC的相当重要的地方。假设你需要有一个在一本书被发布时可以发送 SMS 或者 Email 给订阅者的机制,或者向他们的设备推送通知,就可以在 app/service/ 中创建一个 Notification 服务,并且在你需要该功能时启动这个服务。
国际化/本地化
一开始就要对你的应用进行国际化。不要把这件事情留到最后,否则后面就会变成一个困扰你的大问题。好的网站不会只支持一种语言,它们通常都会有一个更大的目标,目标越大越好。开发时就把国际化当做是最佳的实践之一来考虑。这就是为了 Rails 跟 I18n gem 保持同步, 它表明了对你的应用进行国际化的重要性。关于此你可以看看这里。 这个gem给你提供了如下开箱即用的功能: 支持对英语以及类似语言开箱即用 简化了针对其它语言的定制和扩展操作
它能让你设置一个默认的区域,并且根据用户的所在区域或者偏好的区域设置来进行变更。 如下是一个简单的示例,展示了如何将一段非国际化的 HTML 转变成国际化的 HTML: 1 2 3 4 5 6 7 8 9 10 11 | <h1>Books Listing</h1>
<table>
<thead>
<th>Name</th>
<th>Author</th>
</thead>
<tbody>
<td> Some book </td>
<td> Some author </td>
</tbody>
</table
|
config/locales 目录中的文件被用来支持国际化,它们可以被 Rails 自动加载。每一个 Rails 应用默认都会有一个 config/locales/en.yml 文件。这个文件负责保存英语的翻译。如果你需要添加更多语言的翻译,只要添加名称匹配对应区域且后缀为 .yml 的文件就行了。在本例中我们仍然使用 en.yml,对上面的HTML进行国际化重构: 1 2 3 4 5 6 7 8 9 10 11 | <h1><%= t( '.title' ) %></h1>
<table>
<thead>
<th><%= t( '.name' ) %></th>
<th><%= t( '.author' ) %></th>
</thead>
<tbody>
<td> Some book </td>
<td> Some author </td>
</tbody>
</table>
|
现在将 .yml 文件中所示的内容放进去,这样修改后的 HTML 就可以提取翻译了。 1 2 3 4 5 | # config/en.yml
en:
title: "Books Listing"
name: "Name"
author: "Author"
|
数据库最佳实践
db/schema.rb 文件在其顶部的一段注释中如是说: 强烈建议你将这个文件放到版本控制系统中去。
还有: 如果你需要在另外一个系统上创建应用程序的数据库, 应该使用 db:schema:load, 而不是从头开始将所有的迁移都跑一遍。后者是一种有缺陷并且不可持续的方式 (你做了越多的迁移,运行起来就会越慢,出问题的可能性也越大)。
强烈建议你将这个文件放到版本控制系统中去,如果没有放进去保持更新, 那么就没法利用 rails 的 db:schema:load 命令。正如上面解释的,如果你需要在另外一台机器上创建应用程序的数据库,就应该使用 db:schema:load 而不是 db:migrate。不鼓励重头开始跑所有的迁移,因为这样做会随着时间推移徒生瑕疵。我个人就遇到过这个问题好几次。当迁移出问题时,很难对问题进行跟踪定位, 找出到底是在迁移过程的哪个位置上出问题了。db:schema:load 是这些境况的救星。 注意! db:schema:load 只被用于你需要在一个新系统上创建应用程序数据库的时候。如果只是要添加新的迁移,你应该只要让 db:migrate 来做就行了。如果你在现有的一个已经填充了数据的DB上运行 db:schema:load, 你的数据 (有可能是生产数据) 将会被清除掉。因此只要牢记下面这三条规则,你就是安全的: 在你添加并应用了新的迁移时,总是将对 schema.rb 的修改提交到你的版本控制系统中去。 在一个新系统上创建应用程序数据库是使用 db:schema:load。 所有其它在你需要应用新的迁移的情况下都使用 db:migrate。
提示: 不要使用迁移来向DB添加数据,而是将 db/seeds.rb 用于此目的。 嵌套的资源/路由
如果你拥有一个数据,它属于另外一个资源的子资源, 那么最好对内嵌在父资源路由中的子资源路由进行一下定义, 假如你拥有一个 Post 资源和一个 Comment 资源, 并且有对这些模型的关联进行设置: Post 模型有许多评论 Comment 模型属于 Post
而你的 config/routes.rb 文件看起来像下面这个样子: 1 2 | resources :posts
resources :comments
|
这样救护将你的路由定义成下面这个样子: http://localhost:3000/posts http://localhost:3000/posts/1 http://localhost:3000/posts/1/edit http://localhost:3000/comments http://localhost:3000/comments/1 http://localhost:3000/comments/1/edit
这样还好,但不是一个好的实践。我们应该将 Comment 的路由定义内嵌到 Post 路由中。像这样: 1 2 3 | resources :posts do
resources :comments
end
|
现在这样就会将你的路由定义成下面这个样子: http://localhost:3000/posts http://localhost:3000/posts/1 http://localhost:3000/posts/1/edit http://localhost:3000/posts/1/comments http://localhost:3000/posts/1/comments/1 http://localhost:3000/posts/1/comments/1/edit
URL 是可读的,并且可以预知评论是属于 ID 为 1 的一个 Post。这样做会有一个小麻烦:你必须将你对 Ruby 的表单和URL辅助器的使用方式变化一下。例如,在评论表单中,你得这样: 1 2 3 | <%= form_for( @comment ) do |f| %>
<!-- form elements removed for brevity -->
<% end %>
|
这个需要作出变化。目前它持有 Comment 的一个新的实例,我们需要将它修改成下面这样: 1 2 3 | <%= form_for([ @comment .post, @comment ]) do |f| %>
<!-- form elements removed for brevity -->
<% end %>
|
注意传入到 form_for 辅助器中的参数,它现在是一个数组了, 数组中首先包含了父资源,第二个则是 Comment 实例。 另外一个我们要修改的是所有针对 Comments 的URL 辅助器: 1 | <%= link_to 'Show' , comment %>
|
我们会做如下修改: 1 | <%= link_to 'Show' , [comment.post, comment] %>
|
这样你的展示链接就可以用了。下面来看看修改链接: 1 | <%= link_to 'Edit' , edit_comment_path(comment) %>
|
这个会做如下修改: 1 | <%= link_to 'Edit' , edit_post_comment_path(comment.post, comment) %>
|
注意! 辅助器的名称 (edit_post_comment_path) 以及参数 (两个参数而不是1个)都被修改了,以使其能运行于内嵌的资源/路由。 |
使用 Time.now 代替 Time.zone.now一个最佳实践会始终在文件 config/application.rb 中定义应用程序的默认时区,如下所示: 1 | config.time_zone = ‘Eastern Time (US & Canada)'`.
|
Date.today 和 Time.now 会把所在时区的本地日期和时间返回给机器。然后,使用 Time.zone.now 和 Time.zone.today 能避免开发机器和生产服务器之间的冲突,而且作用显著。 不要在视图中放置太多逻辑视图是表示层,不应包含逻辑。你要尽量避免检查到如下情况: 1 2 3 | <% if book.published? && book.published_at > 1 .weeks.ago %>
<span>Recently added</span>
<% end %>
|
或者 1 2 3 | <% if current_user.roles.collect(&:name).include?( "admin" ) || (user == book.owner && book.draft?) %>
<%= link_to 'Delete' , book, method: :delete, data: { confirm: 'Are you sure?' } %>
<% end %>
|
你可以做的是将这个条件检查移动到帮助模块,他们位于 app/helpers 中,并且在所有视图中自动可用。例如: 1 2 3 4 5 6 7 8 9 10 11 12 | # app/view/helpers/application_helper.rb
module ApplicationHelper
def recently_added?(book)
book.published? && book.published_at > 1 .weeks.ago
end
# current_user is defined in application controller, which can be
# accessed from helper modules & methods
def can_delete?(book)
current_user.roles.collect(&:name).include?( "admin" ) || (user == book.owner && book.draft?)
end
end
|
将上述视图标记修改为: 1 2 3 | <% if recently_added?(book) %>
<span>Recently added</span>
<% end %>
|
和 1 2 3 | <% if can_delete?(book) %>
<%= link_to 'Delete' , book, method: :delete, data: { confirm: 'Are you sure?' } %>
<% end %>
|
还有很多其他地方会用到 can_delete? 的方法,此处只是将逻辑和视图分开的示例。 总结像我在文章开头说到的,如果项目按框架和社区定义的正确方式书写,它能给我们带来便利。这些框架的最佳实践都是由有经验的人整理出来的,他们克服实践中遇到的重重困难,然后总结开发出这些干货,帮助我们解决问题。我们很高兴社区能有这样一群人存在,并得以从他们的经验中获益。Rails 很幸运,它是如此受欢迎,又拥有一个如此伟大的社区,这将使得它越来越受欢迎。 |