來源:Yedingding
春節前受 Terry 邀請幫助國內的一個公益專案 Re-education 做程式碼重構。開放課堂專案是由教育大發現社群發起,成都 ThoughtWorks,成都彩程設計公司,成都超有愛教育科技有限公司等一起合作開發和運營的教育公益網站,是一個提供給小學3-6年級師生設計和開展綜合實踐課的教育開放平臺。專案程式碼放在 GitHub,採用 Ruby on Rails 作為開發框架。
很高興我們 Pragmatic.ly 團隊能參與到這個公益專案的開發中,我相信這是個對社會很有價值的事情。徵得發起方的同意,我把這次重構工作做成了一次線上秀,也正是因為這次這樣的形式,和很多朋友直接在Join.me 上交流了很多 Rails 專案重構方面的想法。通俗點說,重構就是對內要通過修改程式碼結構等方法讓程式碼變得更美,提高可閱讀性和可維護性,而對外不改變原來的行為,不做任何功能的修改。所以我們做重構要做好兩點: 1) 一次只做一件事情,不能修改了多個地方後再做驗證 2) 小步增量前進,路是一步一步走出來的。同時,為了保證重構的正確性,必須要測試保護,每一次小步修改都必須要保證整合測試仍然通過。之所以要保護整合測試而非單元測試,正是因為重構只改變內部結構,而不改變外部行為,所以,單元測試是可能失敗的(其實概率也不高),而整合測試是不允許失敗的。基於 Re-education 的程式碼,這次重構主要涉及了 Controllers 和 Models 兩個方面。有興趣的朋友可以去 RailsCasts China 觀看視訊。
Rails 做為一個 Web 開發框架,幾個哲學一直影響著它的發展,比如 CoC, DRY。而程式碼組織方式,則是按照 MVC 模式,推崇 “Skinny Controller, Fat Model”,把應用邏輯儘可能的放在 Models 中。
Skinny Controller, Fat Model
讓我們來看最實際的例子,來自 Re-education 的程式碼。
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 |
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 相關的邏輯判斷。
從權責而言,Controller 負責的是接收 HTTP Request,並返回 HTTP Response。而具體如何處理和返回什麼資料,則應該交由其他模組比如 Model/View 去完成,Controller 只需要當好控制器即可。所以,從這點上講,如果一個 action 行數超過 10 行,那絕對已經構成了重構點。如果一個 action 對一個 model 變數引用了超過 3 次,也應該構成了重構點。下面是我重構後的程式碼。
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 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 |
在上面的重構中,我主要遵循了兩個方法。
- 把應該屬於 Model 的邏輯從 Controller 移除,放入了 Model。
- 利用虛擬屬性 password, password_confirmation 處理了本不屬於 Publisher Schema 的邏輯。
關於簡化 Controller,多利用 Model 方面的重構方法,Rails Best Practices 有不少不錯的例子,也可以參考。
Beyond Fat Model
對於專案初期而言,做好這兩個基本就夠了。但是,隨著邏輯的增多,程式碼量不斷增加,我們會發現 Models 開始變得臃腫,整體維護性開始降低。如果一個 Model 物件有效程式碼行超過了 100 行,我個人認為因為引起警覺了,要思考一下有沒有重構點。一般而言,我們有下面幾種方法。
Concern
Concern 其實也就是我們通常說的 Shared Mixin Module,也就是把 Controllers/Models 裡面一些通用的應用邏輯抽象到一個 Module 裡面做封裝,我們約定叫它 Concern。而 Rails 4 已經內建支援 Concern, 也就是在建立新 Rails 專案的同時,會建立 app/models/concerns 和 app/controllers/concerns。大家可以看看 DHH 寫的這篇部落格 Put chubby models on a diet with concerns 和 Rails 4 的相關 commit。具體使用可以參照上面的部落格和下面我們在 Pragmatic.ly 裡的實際例子。
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 |
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 是另外一種重構 Models 的利器。所謂委託模式,也就是我們把一些本跟 Model 資料結構淺耦合的東西抽象成一個物件,然後把相關方法委託給這個物件,同樣看看具體例子。
未重構前:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
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 |
當我們需要呼叫的 user_profile 屬性越來越多的時候,會發現方法會不斷增加。這個時候,通過 delegate, 我們可以把程式碼變得更加的簡單。
1 2 3 4 5 6 7 8 9 10 |
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 |
關於更多的如何在 Rails 裡使用 delegate 的方法,參考官方文件 delegate module
Acts As XXX
相信大家對 acts-as-list,acts-as-tree 這些外掛都不陌生,acts-as-xxx 系列其實跟 Concern 差不多,只是它有時不單單是一個 Module,而是一個擁有更多豐富功能的外掛。這個方式在重構 Models 時也是非常的有用。還是舉個例子。
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 44 45 46 47 48 49 50 51 52 53 |
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
如果你在使用了這些方式重構後還是不喜歡程式碼結構,那麼我覺得可能僅僅 MVC 三層就不能滿足你需求了,我們需要更多的抽象,比如 Java 世界廣而告之的 Service 層或者 Presenter 層。這個更多是個人習慣的問題,比如有些人認為應用邏輯(業務邏輯)不應該放在資料層(Model),或者一個 Model 只應該管好他自己的事情,多個 Model 的融合需要另外的類來做代理。關於這些的爭論已經屬於意識形態的範疇,個人的觀點是視需要而定,沒必要一上來就進入 Service 或者 Presenter,保持程式碼的簡單性,畢竟減少專案 Bugs 的永恆不變法就是沒有程式碼。但是,一旦達到可適用範圍,該引入時就引入。這裡也給大家介紹一些我們在用的方法。
Service
之前已經提到 Controller 層應該只接受 HTTP Request,返回 HTTP Response,中間的處理部分應該交由其他部分。我們可以優先把這部分邏輯放在 Model 層處理。但是,Model 層本身從定義而言應該是隻和資料打交道,而不應該過多涉及業務邏輯。這個時候我們就需要用到 Service 層。繼續例子!
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 |
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 |
如果大家仔細分析這段程式碼的話,會發現用 Service 是最好的方案,既不應該放在 Controller,又不適合放在 Model。如果你需要大量使用這種模式,可以考慮一下看看 Imperator 這個 Gem,算是 Rails 世界裡對 Service Layer 實現比較好的庫了。
Presenter
關於 Presenter,不得不提的是一個 Gem ActivePresenter,基本跟 ActiveRecord 的使用方法一樣,如果專案到了一定規模比如有了非常多的 Models,那麼可以關注一下 Presenter 模式,會是一個很不錯的補充。
1 2 3 4 5 6 7 8 |
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 專案裡重構 Controller 和 Model 時會使用的幾種方法,希望對你有用。Terry Tai 上週在他的部落格裡分享了他在重構方面的一些想法,也很有價值,推薦閱讀。