開放課堂項目是由教育大發(fā)現(xiàn)社區(qū)發(fā)起,成都 ThoughtWorks,成都彩程設(shè)計公司,成都超有愛教育科技有限公司等一起合作開發(fā)和運營的教育公益網(wǎng)站,是一個提供給小學(xué)3-6年級師生設(shè)計和開展綜合實踐課的教育開放平臺。項目代碼放在 GitHub,采用 Ruby on Rails 作為開發(fā)框架。
很高興我們 Pragmatic.ly 團隊能參與到這個公益項目的開發(fā)中,我相信這是個對社會很有價值的事情。征得發(fā)起方的同意,我把這次重構(gòu)工作做成了一次在線秀,也正是因為這次這樣的形式,和很多朋友直接在http://sifuyou.com/zhuxian/ 上交流了很多 Rails 項目重構(gòu)方面的想法。通俗點說,重構(gòu)就是對內(nèi)要通過修改代碼結(jié)構(gòu)等方法讓代碼變得更美,提高可閱讀性和可維護性,而對外不改變原來的行為,不做任何功能的修改。所以我們做重構(gòu)要做好兩點: 1) 一次只做一件事情,不能修改了多個地方后再做驗證 2) 小步增量前進,路是一步一步走出來的。同時,為了保證重構(gòu)的正確性,必須要測試保護,每一次小步修改都必須要保證集成測試仍然通過。之所以要保護集成測試而非單元測試,正是因為重構(gòu)只改變內(nèi)部結(jié)構(gòu),而不改變外部行為,所以,單元測試是可能失敗的(其實概率也不高),而集成測試是不允許失敗的?;?Re-education 的代碼,這次重構(gòu)主要涉及了 Controllers 和 Models 兩個方面。有興趣的朋友可以去 RailsCasts China 觀看視頻。
Rails 做為一個 Web 開發(fā)框架,幾個哲學(xué)一直影響著它的發(fā)展,比如 CoC, DRY。而代碼組織方式,則是按照 MVC 模式,推崇 “Skinny Controller, Fat Model”,把應(yīng)用邏輯盡可能的放在 Models 中。
Skinny Controller, Fat Model
讓我們來看最實際的例子,來自 Re-education 的代碼。
class PublishersController < ApplicationController
def create
@publisher = Publisher.new params[:publisher]
# trigger validation
@publisher.valid?
unless simple_captcha_valid? then
@publisher.errors.add :validation_code, "驗證碼有誤"
end
if !(params[:password_copy].eql? @publisher.password) then
@publisher.errors.add :password, "兩次密碼輸入不一致"
end
if @publisher.errors.empty? then
@publisher.password = Digest::MD5.hexdigest @publisher.password
@publisher.save!
session[:user_id] = @publisher.id
redirect_to publisher_path(@publisher)
else
p @publisher.errors
render "new", :layout => true
end
end
end
按照 “Skinny Controller, Fat Model” 的標準,這段代碼有這么幾個問題:
action 代碼量過長
有很多 @publisher 相關(guān)的邏輯判斷。
從權(quán)責(zé)而言,Controller 負責(zé)的是接收 HTTP Request,并返回 HTTP Response。而具體如何處理和返回什么數(shù)據(jù),則應(yīng)該交由其他模塊比如 Model/View 去完成,Controller 只需要當(dāng)好控制器即可。所以,從這點上講,如果一個 action 行數(shù)超過 10 行,那絕對已經(jīng)構(gòu)成了重構(gòu)點。如果一個 action 對一個 model 變量引用了超過 3 次,也應(yīng)該構(gòu)成了重構(gòu)點。下面是我重構(gòu)后的代碼。
class PublishersController < ApplicationController
def create
@publisher = Publisher.new params[:publisher]
if @publisher.save_with_captcha
self.current_user = @publisher
redirect_to publisher_path(@publisher)
else
render "new"
end
end
end
class Publisher < ActiveRecord::Base
apply_simple_captcha :message => "驗證碼有誤"
validates :password,
:presence => {
:message => "密碼為必填寫項"
},
:confirmation => {
:message => "兩次密碼輸入不一致"
}
attr_reader :password
attr_accessor :password_confirmation
def password=(pass)
@password = pass
self.password_digest = encrypt_password(pass) unless pass.blank?
end
private
def encrypt_password(pass)
Digest::MD5.hexdigest(pass)
end
end
在上面的重構(gòu)中,我主要遵循了兩個方法。
把應(yīng)該屬于 Model 的邏輯從 Controller 移除,放入了 Model。
利用虛擬屬性 password, password_confirmation 處理了本不屬于 Publisher Schema 的邏輯。
關(guān)于簡化 Controller,多利用 Model 方面的重構(gòu)方法,Rails Best Practices 有不少不錯的例子,也可以參考。
Move code into model
Add model virtual attribute
Move finder to scope
Beyond Fat Model
對于項目初期而言,做好這兩個基本就夠了。但是,隨著邏輯的增多,代碼量不斷增加,我們會發(fā)現(xiàn) Models 開始變得臃腫,整體維護性開始降低。如果一個 Model 對象有效代碼行超過了 100 行,我個人認為因為引起警覺了,要思考一下有沒有重構(gòu)點。一般而言,我們有下面幾種方法。
Concern
Concern 其實也就是我們通常說的 Shared Mixin Module,也就是把 Controllers/Models 里面一些通用的應(yīng)用邏輯抽象到一個 Module 里面做封裝,我們約定叫它 Concern。而 Rails 4 已經(jīng)內(nèi)建支持 Concern, 也就是在創(chuàng)建新 Rails 項目的同時,會創(chuàng)建 app/models/concerns 和 app/controllers/concerns。大家可以看看 DHH 寫的這篇博客 Put chubby models on a diet with concerns 和 Rails 4 的相關(guān) commit。具體使用可以參照上面的博客和下面我們在 Pragmatic.ly 里的實際例子。
module Membershipable
extend ActiveSupport::Concern
included do
has_many :memberships, as: :membershipable, dependent: :destroy
has_many :users, through: :memberships
after_create :create_owner_membership
end
def add_user(user, admin = false)
Membership.create(membershipable: self, user: user, admin: admin)
end
def remove_user(user)
memberships.find_by_user_id(user.id).try(:destroy)
end
private
def create_owner_membership
self.add_user(owner, true)
after_create_owner_membership
end
def after_create_owner_membership
end
end
class Project < ActiveRecord::Base
include Membershipable
end
class Account < ActiveRecord::Base
include Membershipable
end
通過上面的例子,可以看到 Project 和 Account 都可以擁有很多個用戶,所以 Membershipable 是公共邏輯,可以抽象成 Concern 并在需要的類里面 include,達到了 DRY 的目的。
Delegation Pattern
Delegation Pattern 是另外一種重構(gòu) Models 的利器。所謂委托模式,也就是我們把一些本跟 Model 數(shù)據(jù)結(jié)構(gòu)淺耦合的東西抽象成一個對象,然后把相關(guān)方法委托給這個對象,同樣看看具體例子。
未重構(gòu)前:
class User < ActiveRecord::Base
has_one :user_profile
def birthday
user_profile.try(:birthday)
end
def timezone
user_profile.try(:timezone) || 0
end
def hometown
user_profile.try(:hometown)
end
end
當(dāng)我們需要調(diào)用的 user_profile 屬性越來越多的時候,會發(fā)現(xiàn)方法會不斷增加。這個時候,通過 delegate, 我們可以把代碼變得更加的簡單。
class User < ActiveRecord::Base
has_one :user_profile
delegate :birthday, :tomezone, :hometown, to: :profile
def profile
self.user_profile ||
UserProfile.new(birthday: nil, timezone: 0, hometown: nil)
end
end
關(guān)于更多的如何在 Rails 里使用 delegate 的方法,參考官方文檔 delegate module
Acts As XXX
相信大家對 acts-as-list,acts-as-tree 這些插件都不陌生,acts-as-xxx 系列其實跟 Concern 差不多,只是它有時不單單是一個 Module,而是一個擁有更多豐富功能的插件。這個方式在重構(gòu) Models 時也是非常的有用。還是舉個例子。
module ActiveRecord
module Acts #:nodoc:
module Cache #:nodoc:
def self.included(base)
base.extend(ClassMethods)
end
module ClassMethods
def acts_as_cache(options = { })
klass = options[:class_name] || "#{self.name}Cache".constantize
options[:delegate] ||= []
class_eval <<-EOV
def acts_as_cache_class
::#{klass}
end
after_commit :create_cache, :if => :persisted?
after_commit :destroy_cache, on: :destroy
if #{options[:delegate]}.any?
delegate *#{options[:delegate]}, to: :cache
end
include ::ActiveRecord::Acts::Cache::InstanceMethods
EOV
end
end
module InstanceMethods
def create_cache
acts_as_cache_class.create(self)
end
def destroy_cache
acts_as_cache_class.destroy(self)
end
def cache
acts_as_cache_class.find_or_create_cache(self.id)
end
end
end
end
end
class User < ActiveRecord::Base
acts_as_cache
end
class Project < ActiveRecord::Base
acts_as_cache
end
Beyond MVC
如果你在使用了這些方式重構(gòu)后還是不喜歡代碼結(jié)構(gòu),那么我覺得可能僅僅 MVC 三層就不能滿足你需求了,我們需要更多的抽象,比如 Java 世界廣而告之的 Service 層或者 Presenter 層。這個更多是個人習(xí)慣的問題,比如有些人認為應(yīng)用邏輯(業(yè)務(wù)邏輯)不應(yīng)該放在數(shù)據(jù)層(Model),或者一個 Model 只應(yīng)該管好他自己的事情,多個 Model 的融合需要另外的類來做代理。關(guān)于這些的爭論已經(jīng)屬于意識形態(tài)的范疇,個人的觀點是視需要而定,沒必要一上來就進入 Service 或者 Presenter,保持代碼的簡單性,畢竟減少項目 Bugs 的永恒不變法就是沒有代碼。但是,一旦達到可適用范圍,該引入時就引入。這里也給大家介紹一些我們在用的方法。
Service
之前已經(jīng)提到 Controller 層應(yīng)該只接受 HTTP Request,返回 HTTP Response,中間的處理部分應(yīng)該交由其他部分。我們可以優(yōu)先把這部分邏輯放在 Model 層處理。但是,Model 層本身從定義而言應(yīng)該是只和數(shù)據(jù)打交道,而不應(yīng)該過多涉及業(yè)務(wù)邏輯。這個時候我們就需要用到 Service 層。繼續(xù)例子!
class ProjectHookService
attr_reader :project, :data
def initialize(hook_params = {})
@project = Project.from_param(hook_params)
@data = JSON.parse(hook_params['payload'])
end
def parse
Prly.hook_services.each do |service|
parser = service.new(@project, @data)
if parser.parseable?
parser.parse
end
end
end
def parseable?
@project.present? && @data.present?
end
end
class HooksController < ApplicationController
def create
service = ProjectHookService.new(params)
if service.parseable?
service.parse
render nothing: true, status: 200
else
render text: 'Faled to parse the payload', status: 403
end
end
end
如果大家仔細分析這段代碼的話,會發(fā)現(xiàn)用 Service 是最好的方案,既不應(yīng)該放在 Controller,又不適合放在 Model。如果你需要大量使用這種模式,可以考慮一下看看 Imperator 這個 Gem,算是 Rails 世界里對 Service Layer 實現(xiàn)比較好的庫了。
Presenter
關(guān)于 Presenter,不得不提的是一個 Gem ActivePresenter,基本跟 ActiveRecord 的使用方法一樣,如果項目到了一定規(guī)模比如有了非常多的 Models,那么可以關(guān)注一下 Presenter 模式,會是一個很不錯的補充。
class SignupPresenter < ActivePresenter::Base
presents :user, :account
end
SignupPresenter.new(:user_login => 'dingding',
:user_password => '123456',
:user_password_confirmation => '123456',
:account_subdomain => 'pragmaticly')
We’re good now
基本上上面是我在一個 Rails 項目里重構(gòu) Controller 和 Model 時會使用的幾種方法,希望對你有用。Terry Tai 上周在他的博客里分享了他在重構(gòu)方面的一些想法,也很有價值,推薦閱讀。