此随笔来源于 https://kerzzi.github.io/2017/10/22/2017-10-22-rails-code-refactoring/#1-%E5%B0%86%E4%BB%A3%E7%A0%81%E4%BB%8E-Controller-%E9%87%8D%E6%9E%84%E5%88%B0-Model
转载只为了做笔记。方便查阅。有问题可以联系。
1. 将代码从 Controller 重构到 Model
放在 Controller 的代码一般来说比较难进行重用(re-use)和单元测试,可读性也较差,我们希望将更多代码放在 Model 里面。
方法:
善用 Model scope
,把 where 条件搬到 model 的 scope 宣告,这样就可以在 controller 使用已经定义好的 scope。可读性变好,而且可以在不同地方重复沿用这个 scope。
2. 善用 Model association
情境:新建数据时,想要关联建立的用户
重构前:
1
2
3
4
5
6
7
|
class PostsController < ApplicationController
def create
@post = Post.new(params[
:post])
@post.user_id = current_user.id
@post.save
end
end
|
重构后:由于 User has_many posts
的关系,我们可以用 current_user.posts.build
来取代 @post.user_id = current_user.id
的作用。
1
2
3
4
5
6
|
class PostsController < ApplicationController
def create
@post = current_user.posts.build(params[
:post])
@post.save
end
end
|
1
2
3
|
class
User < ActiveRecord::Base
has_many :posts
end
|
3. 有关联的权限检查 scope access
情境:读取数据时,想要检查用户有没有操作该数据的权限
重构前:需要检查 @post.user
等于 current_user
1
2
3
4
5
6
7
8
9
|
class PostsController < ApplicationController
def edit
@post = Post.find(params[
:id)
if @post.user != current_user
flash[
:warning] = 'Access denied'
redirect_to posts_url
end
end
end
|
重构后:直接用 current_user.posts.find
就可以了。如果该 post 不属于该 user,就会找不到数据。不过,如果权限允许管理员的话,这招就不行了。
1
2
3
4
5
6
7
|
class PostsController < ApplicationController
def edit
# raise RecordNotFound exception (404 error) if not found
@post = current_user.posts.find(params[
:id)
end
end
|
4. 使用 Model 虚拟属性
情境:在表单中,操作不是直接对应 Model 属性的字段。例如下述范例,假设 User model
里面有 first_name
和 last_name
字段,但是画面显示时,我们希望改用 full_name
来操作。这个 full_name
并不是数据库中真正的字段,而是 first_name
和 last_name
两个字段合在一起显示而已。
重构前:表单只能用原始的 text_field_tag
方法,并且在 action 中拆开 params[:full_name]
塞进 model 的 first_nam
e 和 last_name
字段
1
2
3
4
5
6
7
8
9
10
11
|
<% form_for @user
do |f| %>
<%= text_filed_tag
:full_name %>
<%
end %>
class UsersController < ApplicationController
def create
@user = User.new(params[
:user)
@user.first_name = params[
:full_name].split(' ', 2).first
@user.last_name = params[
:full_name].split(' ', 2).last
@user.save
end
end
|
重构后:在 model 中新增 full_name
和 full_name=
方法,这样在表单就可以把 full_name
当作一般的 model 字段使用。而 controller action 更是简化到不需要处理 full_name
。这个 full_name
并没有实际对应数据库的字段,因此称之虚拟属性
。
1
2
3
4
5
6
7
8
9
10
11
|
class User < ActiveRecord::Base
def full_name
[first_name, last_name].join(
' ')
end
def full_name=(name)
split = name.split(
' ', 2)
self.first_name = split.first
self.last_name = split.last
end
end
|
1
2
3
|
<% form_for @user do |f| %>
<%= f.text_field :full_name %>
<% end %>
|
1
2
3
4
5
6
7
|
class UsersController < ApplicationController
def create
@user = User.create(params[
:user)
end
end
|
5. 使用 Model 回呼(callback)
情境:新增文章时,有一个核选方块 auto_tagging
,如果打勾表示想要系统自动下标籤。
重构前:需要在 action 中检查 params[:auto_tagging]
,然后调用 model 方法产生标籤(这里假设有一个 AsiaSearch.generate_tags 方法可以自动下标籤)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
<% form_for @post
do |f| %>
<%= f.text_field
:content %>
<%= check_box_tag
'auto_tagging' %>
<%
end %>
class PostController < ApplicationController
def create
@post = Post.new(params[
:post])
if params[:auto_tagging] == '1'
@post.tags = AsiaSearch.generate_tags(@post.content)
else
@post.tags =
""
end
@post.save
end
end
|
重构后:新增一个虚拟属性 auto_tagging,以及一个 before_save 回呼来检查要不要自动下标籤。如此在 action 中就可以不必检查和处理 auto_tagging。这段过程会自动在 Post 存储前,自动调用 generate_taggings 方法进行处理。
1
2
3
4
5
6
7
8
9
|
class Post < ActiveRecord::Base
attr_accessor :auto_tagging
before_save
:generate_taggings
private
def generate_taggings
return unless auto_tagging == '1' self.tags = Asia.search(self.content)
end
end
|
1
2
3
4
|
<% form_for :note, ... do |f| %>
<%= f.text_field :content %>
<%= f.check_box :auto_tagging %>
<% end %>
|
1
2
3
4
5
6
|
class PostController < ApplicationController
def create
@post = Post.new(params[
:post])
@post.save
end
end
|
6. 将逻辑放到 Model
情境:有一个发布文章 publish 的 action 有很多操作的相关步骤,需要设定很多 model 字段。
重构前:所有操作都在 action 之中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
class PostController < ApplicationController
def publish
@post = Post.find(params[
:id])
@post.is_published =
true
@post.approved_by = current_user
if @post.create_at > Time.now - 7.days
@post.popular =
100
else
@post.popular =
0
end
@post.save
redirect_to post_url(@post)
end
end
|
重构后:把相关的操作全部搬到 Model 的自定义方法 publish!
,这样 action 中只需要调用 @post.publish!
即可,非常可读清楚。这个 publish!
方法也可以在其它各处使用。
1
2
3
4
5
6
7
8
9
10
11
12
|
class Post < ActiveRecord::Base
def publish!(user)
self.is_published = true
self.approved_by = user
if self.create_at > Time.now-7.days
self.popular = 100
else
self.popular = 0
end
self.save!
end
end
|
1
2
3
4
5
6
7
8
|
class PostController < ApplicationController
def publish
@post = Post.find(params[
:id])
@post.publish!(current_user)
redirect_to post_url(@post)
end
end
|
7. 使用工厂方法(Factory Method)取代复杂的建构过程
情境:建立 model 需要复杂的建构过程,例如以下建构 Invoice 需要设定很多属性。
重构前:都在 create action 中完成
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
class InvoiceController < ApplicationController
def create
@invoice = Invoice.new(params[
:invoice])
@invoice.address = current_user.address
@invoice.phone = current_user.phone
@invoice.vip = ( @invoice.amount >
1000 )
if Time.now.day > 15
@invoice.delivery_time = Time.now +
2.month
else
@invoice.delivery_time = Time.now +
1.month
end
@invoice.save
end
end
|
重构后:在 model 中新写一个类方法 new_by_user
,把建构的过程全部搬进来。这样 controller action 里面只需要调用这个方法即可。这一招跟上一节是一样的道理。这种建构用途的方法又叫做工厂方法
。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
class Invoice < ActiveRecord::Base
def self.new_by_user(params, user)
invoice = self.
new(params)
invoice.address = user.address
invoice.phone = user.phone
invoice.vip = ( invoice.amount >
1000 )
if Time.now.day > 15
invoice.delivery_time =
Time.now + 2.month
else
invoice.delivery_time =
Time.now + 1.month
end
return invoice
end
end
|
1
2
3
4
5
6
|
class InvoiceController < ApplicationController
def create
@invoice = Invoice.new_by_user(params[
:invoice], current_user)
@invoice.save
end
end
|
8. 善用 Module 抽取相关代码(不太懂)
情境:如果将代码从 Controller 重构到 Model 做得不错了,接下来如何进一步重构 Model 代码?
重构前:在 Model 中有一些高度相关的代码,希望能够更清楚他们是一起的,或是希望能在不同 Model 中也能重复使用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
class User < ActiveRecord::Base
validates_presence_of
:cellphone
before_save
:parse_cellphone
def parse_cellphone
# do something
end
def self.foobar
# do something
end
end
|
重构后:善用 Ruby 的 module 语法,可以将这些相关代码抽取出来,放在 app/models/concerns
目录下,包括 model 的 validates 宣告、回呼宣告、关联宣告、对象方法、类方法等等都可抽取出来。
app/models/concerns/has_cellphone.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
module HasCellphone
def self.included(base)
base.validates_presence_of
:cellphone
base.before_save
:parse_cellphone
base.extend ClassMethods
end
def parse_cellphone
# do something
end
module ClassMethods
def foobar
# do something
end
end
end
|
或是使用 Rails ActiveSupport::Concern 语法,可以更简洁一点:
app/models/concerns/has_cellphone.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
module HasCellphone
extend ActiveSupport::Concern
included
do
validates_presence_of
:cellphone
before_save
:parse_cellphone
end
def parse_cellphone
# do something
end
class_methods
do
def foobar
# do something
end
end
end
|
最后在 model 里面 include 即可。
1
2
3
|
class
User < ActiveRecord::Base
include HasCellphone
end
|
其他 model 也可以沿用这段代码,只要 include 即可:
1
2
3
|
class Contact < ActiveRecord::Base
include HasCellphone
end
|
这一招其实 controller 也可以用,在 rails 中 app/controllers/concerns
目录就是拿来放 controller 的 module 档案
关于 View,最重要的守则就是在 template 中绝对没有商务逻辑。
9. 将代码重构到 Model
情境:在 View 中需要一些条件判断,来决定要不要显示某些内容
重构前:需要检查有登入、该用户是该篇文章作者或是编辑员
1
2
3
4
|
<% if current_user && (current_user == @post.user ||
@post.editors.include?(current_user) %>
<%= link_to 'Edit this post', edit_post_url(@post) %>
<% end %>
|
重构后:这一整段条件判断,可以重构到 Model 的一个方法。这样 View 里面只需要调用 @post.editable_by?(current_user)
即可,代码干净又清楚。
1
2
3
4
5
6
7
8
|
<%
if @post.editable_by?(current_user) %>
<%= link_to
'Edit this post', edit_post_url(@post) %>
<%
end %>
class Post < ActiveRecord::Base
def ediable_by?(user)
user && ( user ==
self.user || self.editors.include?(user)
end
end
|
10.将代码重构到 Helper
情境:
- HTML 与 Ruby 高度混杂
- 该段程式码有很多 if / else
- 该段程式码衣服穿很多层 simple_format(truncate(auto_link(@post.content), :length => 30) )
重构前:我们想要把文章内容自动加超连结、截断只留前30字前、保留换行等等:
<%= simple_format(truncate(auto_link(@post.content), :length => 30) ) %>
重构后:抽取出一个 pretty_content
helper 方法,这样在各处 template 都可以重复使用这个 helper,而且也比较清楚。
1
2
3
4
5
6
|
app/helpers/posts_helper.rb
module PostsHelper
def pretty_content(content)
simple_format(truncate(auto_link(content),
:length => 30) )
end
end
|
<%= pretty_content(@post.content) %>
那到底什么情境适合把代码重构到 Model? 什么时候用 Helper 呢?如果跟 HTML 画面显示无关,跟商务逻辑有关,则放到 Model 里面。如果跟 HTML 画面显示有关,则适合放在 Helper 里面。一般来说,在 Model 里面是不会处理 HTML 代码的,这是 Helper 的事情。
11. 将代码重构到 Partial 样板
情境:Helper 是 Ruby 代码,里面不适合放太多的 HTML。如果你有一整段的 HTML 代码想要抽取出来,应该用 Partial 样板。
重构前:
1
2
3
4
5
6
7
|
def render_product_item(
product)
content_tag(
:div, :class => "col-md-3") do
content_tag(
:div, link_to(product.title), product_path(product) ) +
content_tag(
:p, tag(:hr)) +
content_tag(
:span, product.price + "元")
end
end
|
<%= render_product_item(@product) %>
重构后:应该改用 partial 而不是 helper。
1
2
3
4
5
6
7
8
|
app/views/products/_item.html.erb
<div class="col-md-3">
<div><%=link_to(product.title), product_path(product) %>div>
<p>
<hr>
<span><%= product.price %>元span>
p>
div>
|
<%= render :partial => "item", :locals => { :product => @product } %>
12.使用 Partial 尽量用区域变量取代对象变量
情境:在使用 Partial 时
1
2
3
4
5
|
class Post < ApplicationController
def show
@post = Post.find(params[
:id)
end
end
|
重构前:在 action 中宣告的对象变量例如 @post
,会穿透到这个 template 内所有使用的 partial。虽然很方便,但是如果 partial 内要用到的变量很多,就会搞不清楚到底要准备哪些变量才能使用这个 partial。
<%= render :partial => "sidebar" %>
_sidebar.html.erb
1
|
<%= @post.title %>
|
重构后:将 @post 传入这个 partial,这样在这个 partial 里面,就会变成区域变量 post
。这个好处是可以增加这个 partial 的可重复使用性,也比较清楚要传这些变量才能使用。
<%= render :partial => "sidebar", :locals => { :post => @post } %>
_sidebar.html.erb
1
|
<%= post.title %>
|
13. 整理 Helper 档案
情境:每次 rails g controller XXX 时,Rails 都会自动新增对应的 XXX_helper.rb 档案
重构前:很多 Helper 档案打开来里面都是空的,没有用到
1
2
3
4
|
app/helpers/user_posts_helper.rb
app/helpers/author_posts_helper.rb
app/helpers/editor_posts_helper.rb
app/helpers/admin_posts_helper.rb
|
重构后:简化集中到少数的 Helper 档案即可。这些 Helper 档案跟 Controller 并没有对应的关系,所有 Helper 档案里面宣告的方法都是通用的,不会因为放在哪一个 Helper 档案而有差异。因此我们可以重新编排整理。
app/helpers/posts_helper.rb
14. 代码分析工具
- Rubocop 是一个 gem 可以分析 Rails 代码,建议一些可以重构的地方
- CodeClimate 是一个线上的工具,可以为项目评分,并建议哪里需要修改。
15. 补充和推荐资源
- 重构 这本书是软件开发领域的经典之作,原作使用 Java 代码,但是仍然值得一读。另有 Ruby 版本。
- how DHH organizes his rails controllers Rails 发明人 DHH 如何组织 Controller 代码,坚持 RESTful
- Put chubby models on a diet with concerns DHH 对于 concerns 用法的看法
当你仍不满足 Rails 的 concerns 机制时,你会需要更多面向对象的知识,在编程语言教程中有介绍到。关于 Rails 的部分推荐以下补充资料:
- 7 Patterns to Refactor Fat ActiveRecord Models
- Object Oriented Rails – Writing better controllers
- Slimming Down Your Models and Controllers with Concerns, Service Objects, and Tableless Models
- Growing Rails Applications in Practice