重構Rails MVC組件的7個設計模式

原文請閱讀 7 Design Patterns to Refactor MVC Components in Rails

MVC
MVC
  1. [Service Objects (and Interactor Objects)](#1. Service Objects (and Interactor Objects))
  2. [Value Objects](#2. Value Objects)
  3. [Form Objects](#3. Form Objects)
  4. [Query Objects](#4. Query Objects)
  5. [View Objects (Serializer/Presenter)](#5. View Objects (Serializer/Presenter))
  6. [Policy Objects](#6. Policy Objects)
  7. [Decorators](#7. Decorators)

1. Service Objects (and Interactor Objects)

當Controller中的action有以下癥狀時適用:

  • 過于復雜(如,計算員工的工資)
  • 調用外部api服務
  • 明顯不屬于任何model (如, 刪除過期數(shù)據(jù))
  • 使用多個model(如,從一個文件中導入數(shù)據(jù)到多個model)

示例

以下示例中,主要工作由外部Stripe服務完成。該服務基于郵件地址和來源創(chuàng)建Stripe客戶,并將所有服務費用綁定到該客戶的賬號上。

問題分析

  • Controller中包含調用外部服務的代碼
  • Controller負責構建調用外部服務所需的數(shù)據(jù)
  • Controller難于維護和擴展
class ChargesController < ApplicationController
   def create
     amount = params[:amount].to_i * 100
     customer = .create(
       email: params[:email],
       source: params[:source]
     )
     charge = Stripe::Charge.create(
       customer: customer.id,
       amount: amount,
       description: params[:description],
       currency: params[:currency] || 'USD'
     )
     redirect_to charges_path
   rescue Stripe::CardError => exception
     flash[:error] = exception.message
     redirect_to new_charge_path
   end
  end

為了解決這些問題,可以將其封裝為一個外部服務。

class ChargesController < ApplicationController
 def create
   CheckoutService.new(params).call
   redirect_to charges_path
 rescue Stripe::CardError => exception
   flash[:error] = exception.message
   redirect_to new_charge_path
 end
end
class CheckoutService
 DEFAULT_CURRENCY = 'USD'.freeze

 def initialize(options = {})
   options.each_pair do |key, value|
     instance_variable_set("@#{key}", value)
   end
 end

 def call
   Stripe::Charge.create(charge_attributes)
 end

 private

 attr_reader :email, :source, :amount, :description

 def currency
   @currency || DEFAULT_CURRENCY
 end

 def amount
   @amount.to_i * 100
 end

 def customer
   @customer ||= Stripe::Customer.create(customer_attributes)
 end

 def customer_attributes
   {
     email: email,
     source: source
   }
 end

 def charge_attributes
   {
     customer: customer.id,
     amount: amount,
     description: description,
     currency: currency
   }
 end
end

最終由CheckoutService來負責客戶賬號的創(chuàng)建和支付,從而解決了Controller中業(yè)務代碼過多的問題。但是,還有一個問題需要解決。如果外部服務拋出異常時(如,信用卡無效)該如何處理,需要重定向的其他頁面嗎?

class ChargesController < ApplicationController
 def create
   CheckoutService.new(params).call
   redirect_to charges_path
 rescue Stripe::CardError => exception
   flash[:error] = exception.error
   redirect_to new_charge_path
 end
end

為了解決這個問題,可以在一個Interactor對象中調用CheckoutService,并捕獲可能產(chǎn)生的異常。Interactor模式常用于封裝業(yè)務邏輯,每個Interactor一般只描述一條業(yè)務邏輯。

Interactor模式通過簡單Ruby對象(plain old Ruby objects, POROs)可以幫助我們實現(xiàn)單一原則(Single Responsibility Principle, SRP)。Interactor與Service Object類似,只是通常會返回執(zhí)行狀態(tài)及相關信息,而且一般會在Interactor內部使用Service Object。下面是該設計模式的使用示例:

class ChargesController < ApplicationController
 def create
   interactor = CheckoutInteractor.call(self)

   if interactor.success?
     redirect_to charges_path
   else
     flash[:error] = interactor.error
     redirect_to new_charge_path
   end
 end
end
class CheckoutInteractor
 def self.call(context)
   interactor = new(context)
   interactor.run
   interactor
 end

 attr_reader :error

 def initialize(context)
   @context = context
 end

 def success?
   @error.nil?
 end

 def run
   CheckoutService.new(context.params)
 rescue Stripe::CardError => exception
   fail!(exception.message)
 end

 private

 attr_reader :context

 def fail!(error)
   @error = error
 end
end

移除所有信用卡錯誤相關的異常,Controller就達到了瘦身的目的。瘦身以后,Controller只負責成功支付和失敗支付時的頁面跳轉。

2. Value Objects

Value Object設計模式推崇簡潔的對象(僅包含一些給定的值),并支持根據(jù)給定的邏輯,或基于指定的屬性進行對象間相互比較(不基于id)。Value Object的例子如,以不同幣種表示的貨幣。我們可以用一個幣種(如,美元)來比較這些對象。同樣,Value Object也可以用于表示溫度,并可用單位開來進行比較。

示例

假設有一所帶電加熱的智能房子,加熱器可以通過網(wǎng)絡接口加以控制。Controller的一個方法將從溫度傳感器那里收到指定加熱器的參數(shù):溫度數(shù)值和溫度單位(華氏、攝氏或開)。如果是其他溫度單位,一律先轉換為開。然后,檢查溫度是否小于25°C并大于等于當前溫度。

問題分析

Controller中包含了太多與溫度轉換和比較相關的邏輯代碼。

class AutomatedThermostaticValvesController < ApplicationController
  SCALES = %w(kelvin celsius fahrenheit)
  DEFAULT_SCALE = 'kelvin'
  MAX_TEMPERATURE = 25 + 273.15

  before_action :set_scale

  def heat_up
    was_heat_up = false
    if previous_temperature < next_temperature && next_temperature < MAX_TEMPERATURE
      valve.update(degrees: params[:degrees], scale: params[:scale])
      Heater.call(next_temperature)
      was_heat_up = true
    end
    render json: { was_heat_up: was_heat_up }
  end

  private

  def previous_temperature
    kelvin_degrees_by_scale(valve.degrees, valve.scale)
  end

  def next_temperature
    kelvin_degrees_by_scale(params[:degrees], @scale)
  end

  def set_scale
    @scale = SCALES.include?(params[:scale]) ? params[:scale] : DEFAULT_SCALE
  end

  def valve
    @valve ||= AutomatedThermostaticValve.find(params[:id])
  end

  def kelvin_degrees_by_scale(degrees, scale)
    degrees = degrees.to_f
    case scale.to_s
    when 'kelvin'
      degrees
    when 'celsius'
      degrees + 273.15
    when 'fahrenheit'
      (degrees - 32) * 5 / 9 + 273.15
    end
  end
end

首先,將溫度比較邏輯移到Model中,Controller只需要將參數(shù)傳給`update'方法。但這樣一來,Model就包含了太多溫度轉換的代碼。

class AutomatedThermostaticValvesController < ApplicationController
  def heat_up
    valve.update(next_degrees: params[:degrees], next_scale: params[:scale])

    render json: { was_heat_up: valve.was_heat_up }
  end

  private

  def valve
    @valve ||= AutomatedThermostaticValve.find(params[:id])
  end
end
class AutomatedThermostaticValve < ActiveRecord::Base
  SCALES = %w(kelvin celsius fahrenheit)
  DEFAULT_SCALE = 'kelvin'

  before_validation :check_next_temperature, if: :next_temperature
  after_save :launch_heater, if: :was_heat_up

  attr_accessor :next_degrees, :next_scale
  attr_reader :was_heat_up

  def temperature
    kelvin_degrees_by_scale(degrees, scale)
  end

  def next_temperature
    kelvin_degrees_by_scale(next_degrees, next_scale) if next_degrees.present?
  end

  def max_temperature
    kelvin_degrees_by_scale(25, 'celsius')
  end

  def next_scale=(scale)
    @next_scale = SCALES.include?(scale) ? scale : DEFAULT_SCALE
  end

  private

  def check_next_temperature
    @was_heat_up = false
    if temperature < next_temperature && next_temperature <= max_temperature
      @was_heat_up = true
      assign_attributes(
        degrees: next_degrees,
        scale: next_scale,
      )
    end
    @was_heat_up
  end

  def launch_heater
    Heater.call(temperature)
  end

  def kelvin_degrees_by_scale(degrees, scale)
    degrees = degrees.to_f
    case scale.to_s
    when 'kelvin'
      degrees
    when 'celsius'
      degrees + 273.15
    when 'fahrenheit'
      (degrees - 32) * 5 / 9 + 273.15
    end
  end
end

為了讓Model瘦身,我們將創(chuàng)建Value Objects。Value Objects接受溫度數(shù)值和溫度單位作為初始化參數(shù)。在進行比較時,使用<=>操作符比較轉換為開之后的溫度。

同時,Value Object也包含一個to_h方法用于批量賦值。另外,還提供了工廠方法from_kelvin、from_celsiusfrom_fahrenheit,便于以指定單位創(chuàng)建Temperature對象,如Temperature.from_celsius(0)將會創(chuàng)建一個0°C或273°К的溫度對象。

class AutomatedThermostaticValvesController < ApplicationController
  def heat_up
    valve.update(next_degrees: params[:degrees], next_scale: params[:scale])
    render json: { was_heat_up: valve.was_heat_up }
  end

  private

  def valve
    @valve ||= AutomatedThermostaticValve.find(params[:id])
  end
end
class AutomatedThermostaticValve < ActiveRecord::Base
  before_validation :check_next_temperature, if: :next_temperature
  after_save :launch_heater, if: :was_heat_up

  attr_accessor :next_degrees, :next_scale
  attr_reader :was_heat_up

  def temperature
    Temperature.new(degrees, scale)
  end

  def temperature=(temperature)
    assign_attributes(temperature.to_h)
  end

  def next_temperature
    Temperature.new(next_degrees, next_scale) if next_degrees.present?
  end

  private

  def check_next_temperature
    @was_heat_up = false
    if temperature < next_temperature && next_temperature <= Temperature::MAX
      self.temperature = next_temperature
      @was_heat_up = true
    end
  end

  def launch_heater
    Heater.call(temperature.kelvin_degrees)
  end
end
class Temperature
  include Comparable
  SCALES = %w(kelvin celsius fahrenheit)
  DEFAULT_SCALE = 'kelvin'

  attr_reader :degrees, :scale, :kelvin_degrees

  def initialize(degrees, scale = 'kelvin')
    @degrees = degrees.to_f
    @scale = case scale
    when *SCALES then scale
    else DEFAULT_SCALE
    end

    @kelvin_degrees = case @scale
    when 'kelvin'
      @degrees
    when 'celsius'
      @degrees + 273.15
    when 'fahrenheit'
      (@degrees - 32) * 5 / 9 + 273.15
    end
  end

  def self.from_celsius(degrees_celsius)
    new(degrees_celsius, 'celsius')
  end

  def self.from_fahrenheit(degrees_fahrenheit)
    new(degrees_celsius, 'fahrenheit')
  end

  def self.from_kelvin(degrees_kelvin)
    new(degrees_kelvin, 'kelvin')
  end

  def <=>(other)
    kelvin_degrees <=> other.kelvin_degrees
  end

  def to_h
    { degrees: degrees, scale: scale }
  end

  MAX = from_celsius(25)
end

最終的結果是,Controller和Model同時得到了瘦身。Controller不包含任何與溫度相關的業(yè)務邏輯,Model也不包含任何與溫度轉換相關的邏輯,僅調用了Temperature提供的方法。

3. Form Objects

Form Object模式適用于封裝數(shù)據(jù)校驗和持久化。

示例

假設我們有一個典型Rails Model和Controller用于創(chuàng)建新用戶。

問題分析

Model中包含了所有校驗邏輯,因此不能為其他實體重用,如Admin。

class UsersController < ApplicationController
  def create
    @user = User.new(user_params)

    if @user.save
      render json: @user
    else
      render json: @user.error, status: :unprocessable_entity
    end
  end

  private

  def user_params
    params
      .require(:user)
      .permit(:email, :full_name, :password, :password_confirmation)
  end
end
class User < ActiveRecord::Base
  EMAIL_REGEX = /@/ # Some fancy email regex

  validates :full_name, presence: true
  validates :email, presence: true, format: EMAIL_REGEX
  validates :password, presence: true, confirmation: true
end

解決方案就是將所有校驗邏輯移到一個單獨負責校驗的類中,可以稱之為UserForm。

class UserForm
  EMAIL_REGEX = // # Some fancy email regex

  include ActiveModel::Model
  include Virtus.model

  attribute :id, Integer
  attribute :full_name, String
  attribute :email, String
  attribute :password, String
  attribute :password_confirmation, String

  validates :full_name, presence: true
  validates :email, presence: true, format: EMAIL_REGEX
  validates :password, presence: true, confirmation: true

  attr_reader :record

  def persist
    @record = id ? User.find(id) : User.new

    if valid?
      @record.attributes = attributes.except(:password_confirmation, :id)
      @record.save!
      true
    else
      false
    end
  end
end

現(xiàn)在,就可以在Controller里面像這樣使用它了:

class UsersController < ApplicationController
  def create
    @form = UserForm.new(user_params)

    if @form.persist
      render json: @form.record
    else
      render json: @form.errors, status: :unpocessably_entity
    end
  end

  private

  def user_params
    params.require(:user)
          .permit(:email, :full_name, :password, :password_confirmation)
  end
end

最終,用戶Model不在負責校驗數(shù)據(jù):

class User < ActiveRecord::Base
end

4. Query Objects

該模式適用于從Controller和Model中抽取查詢邏輯,并將它封裝到可重用的類。

示例

假設我們請求一個文章列表,查詢條件是類型為video、查看數(shù)大于100并且當前用戶可以訪問。

問題分析

所有查詢邏輯都在Controller中(即所有查詢條件都在Controller中添加)。

  • 不可重用
  • 難于測試
  • 文章Scheme的任何改變都可能影響這段代碼
class Article < ActiveRecord::Base
    # t.string :status
    # t.string :type
    # t.integer :view_count
  end

 class ArticlesController < ApplicationController
    def index
      @articles = Article
                  .accessible_by(current_ability)
                  .where(type: :video)
                  .where('view_count > ?', 100)
    end
  end

重構的第一步就是封裝查詢條件,提供簡潔的API接口。在Rails中,可以使用scope實現(xiàn):

class Article < ActiveRecord::Base
  scope :with_video_type, -> { where(type: :video) }
  scope :popular, -> { where('view_count > ?', 100) }
  scope :popular_with_video_type, -> { popular.with_video_type }
end

現(xiàn)在就可以使用這些簡潔的API接口來查詢,而不用關心底層是如何實現(xiàn)的。如果article的scheme發(fā)生了改變,僅需要修改article類即可。

class ArticlesController < ApplicationController 
  def index 
    @articles = Article 
                .accessible_by(current_ability) 
                .popular_with_video_type 
  end
end

看起來不錯,不過又有一些新問題出現(xiàn)了。首先,需要為每個想要封裝的查詢條件創(chuàng)建scope,最終會導致Model中充斥諸多針對不同應用場景的scope組合。其次,scope不能在不同的model中重用,比如不用使用Article的scope來查詢Attachment。最后,將所有查詢相關的邏輯都塞到Article類中也違反了單一原則。解決方案是使用Query Object。

class PopularVideoQuery 
  def call(relation) 
    relation 
      .where(type: :video) 
      .where('view_count > ?', 100) 
  end
end

class ArticlesController < ApplicationController 
  def index 
    relation = Article.accessible_by(current_ability) 
    @articles = PopularVideoQuery.new.call(relation) 
  end
end

哈,這樣就可以做到重用了!現(xiàn)在可以將它用于查詢任何具有相似scheme的類了:

class Attachment < ActiveRecord::Base 
  # t.string :type 
  # t.integer :view_count
end

PopularVideoQuery.new.call(Attachment.all).to_sql
# "SELECT \"attachments\".* FROM \"attachments\" WHERE \"attachments\".\"type\" = 'video' AND (view_count > 100)"
PopularVideoQuery.new.call(Article.all).to_sql
# "SELECT \"articles\".* FROM \"articles\" WHERE \"articles\".\"type\" = 'video' AND (view_count > 100)"

如果想進一步支持鏈式調用的話,也很簡單。只需要讓call方法遵循ActiveRecord::Relation接口即可:

class BaseQuery
  def |(other)
    ChainedQuery.new do |relation|
      other.call(call(relation))
    end
  end
end

class ChainedQuery < BaseQuery
  def initialize(&block)
    @block = block
  end

  def call(relation)
    @block.call(relation)
  end
end

class WithStatusQuery < BaseQuery
  def initialize(status)
    @status = status
  end

  def call(relation)
    relation.where(status: @status)
  end
end

query = WithStatusQuery.new(:published) | PopularVideoQuery.new
query.call(Article.all).to_sql
# "SELECT \"articles\".* FROM \"articles\" WHERE \"articles\".\"status\" = 'published' AND \"articles\".\"type\" = 'video' AND (view_count > 100)"

現(xiàn)在,我們得到了一個封裝所有查詢邏輯,可重用,提供簡潔接口并易于測試的類。

5. View Objects (Serializer/Presenter)

View Object適用于將View中的數(shù)據(jù)及相關計算從Controller和Model抽離出來,如一個網(wǎng)站的HTML頁面或API終端請求的JSON響應。

示例

View中一般通常存在以下計算:

  • 根據(jù)服務器協(xié)議和圖片路徑創(chuàng)建圖片URL
  • 獲取文章的標題和描述,如果沒有返回默認值
  • 連接姓和名來顯示全名
  • 用合適的方式顯示文章的創(chuàng)建日期

問題分析

View中包含了太多計算邏輯。

# 重構之前
#/app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
 def show
   @article = Article.find(params[:id])
 end
end

#/app/views/articles/show.html.erb
<% content_for :header do %>
 <title>
     <%= @article.title_for_head_tag || I18n.t('default_title_for_head') %>
 </title>
 <meta name='description' content="<%= @article.description_for_head_tag || I18n.t('default_description_for_head') %>">
  <meta property="og:type" content="article">
  <meta property="og:title" content="<%= @article.title %>">
  <% if @article.description_for_head_tag %>
    <meta property="og:description" content="<%= @article.description_for_head_tag %>">
  <% end %>
  <% if @article.image %>
     <meta property="og:image" content="<%= "#{request.protocol}#{request.host_with_port}#{@article.main_image}" %>">
  <% end %>
<% end %>

<% if @article.image %>
 <%= image_tag @article.image.url %>
<% else %>
 <%= image_tag 'no-image.png'%>
<% end %>
<h1>
 <%= @article.title %>
</h1>

<p>
 <%= @article.text %>
</p>

<% if @article.author %>
<p>
 <%= "#{@article.author.first_name} #{@article.author.last_name}" %>
</p>
<%end%>

<p>
 <%= t('date') %>
 <%= @article.created_at.strftime("%B %e, %Y")%>
</p>

為了解決這個問題,可以先創(chuàng)建一個presenter基類,然后再創(chuàng)建一個ArticlePresenter類的實例。ArticlePresenter方法根據(jù)適當?shù)挠嬎惴祷叵胍臉撕灐?/p>

#/app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
 def show
   @article = Article.find(params[:id])
 end
end

#/app/presenters/base_presenter.rb
class BasePresenter
 def initialize(object, template)
   @object = object
   @template = template
 end

 def self.presents(name)
   define_method(name) do
     @object
   end
 end

 def h
   @template
 end
end

#/app/helpers/application_helper.rb
module ApplicationHelper
  def presenter(model)
    klass = "#{model.class}Presenter".constantize
    presenter = klass.new(model, self)
    yield(presenter) if block_given?
  end
end

#/app/presenters/article_presenters.rb
class ArticlePresenter < BasePresenter
 presents :article
 delegate :title, :text, to: :article

 def meta_title
   title = article.title_for_head_tag || I18n.t('default_title_for_head')
   h.content_tag :title, title
 end

 def meta_description
   description = article.description_for_head_tag || I18n.t('default_description_for_head')
   h.content_tag :meta, nil, content: description
 end

 def og_type
   open_graph_meta "article", "og:type"
 end
  def og_title
   open_graph_meta "og:title", article.title
 end

 def og_description
   open_graph_meta "og:description", article.description_for_head_tag if article.description_for_head_tag
 end

 def og_image
   if article.image
     image = "#{request.protocol}#{request.host_with_port}#{article.main_image}"
     open_graph_meta "og:image", image
   end
 end

 def author_name
   if article.author
     h.content_tag :p, "#{article.author.first_name} #{article.author.last_name}"
   end
 end

 def image
  if article.image
    h.image_tag article.image.url
  else
     h.image_tag 'no-image.png'
  end
 end

 private
 def open_graph_meta content, property
   h.content_tag :meta, nil, content: content, property: property
 end
end

現(xiàn)在View中不包含任何與計算相關的邏輯,所有組件都抽離到了presenter中,并可在其他View中重用,如下:

#/app/views/articles/show.html.erb
<% presenter @article do |article_presenter| %>
 <% content_for :header do %>
   <%= article_presenter.meta_title %>
   <%= article_presenter.meta_description %>
   <%= article_presenter.og_type %>
   <%= article_presenter.og_title %>
   <%= article_presenter.og_description %>
   <%= article_presenter.og_image %>
 <% end %>

 <%= article_presenter.image%>
 <h1> <%= article_presenter.title %> </h1>
 <p>  <%= article_presenter.text %> </p>
 <%= article_presenter.author_name %>
<% end %>

6. Policy Objects

Policy Object模式與Service Object模式相似,前者負責讀操作,后者負責寫操作。Policy Object模式適用于封裝復雜的業(yè)務規(guī)則,并易于替換。比如,可以使用一個訪客Policy Object來識別一個訪客是否可以訪問某些特定資源。當用戶是管理員時,可以很方便的將訪客Policy Object替換為包含管理員規(guī)則的管理員Policy Object。

示例

在用戶創(chuàng)建一個項目之前,Controller將檢查當前用戶是否為管理者,是否有權限創(chuàng)建項目,當前用戶項目數(shù)量是否小于最大值,以及在Redis中是否存在阻塞的項目創(chuàng)建。

問題分析

  • 自由Controller知道項目創(chuàng)建的規(guī)則
  • Controller包含了額外的邏輯代碼
class ProjectsController < ApplicationController
   def create
     if can_create_project?
       @project = Project.create!(project_params)
       render json: @project, status: :created
     else
       head :unauthorized
     end
   end

  private

  def can_create_project?
     current_user.manager? &&
       current_user.projects.count < Project.max_count &&
       redis.get('projects_creation_blocked') != '1'
   end

  def project_params
     params.require(:project).permit(:name, :description)
  end

  def redis
    Redis.current
  end
end

class User < ActiveRecord::Base
  enum role: [:manager, :employee, :guest]
end

為了讓Controller瘦身,可以將規(guī)則代碼移到Model中。所有檢查邏輯都將移出Controller。但是這樣一來,User類就知道了Redis和Project類的邏輯。并且Model也變胖了。

class User < ActiveRecord::Base
  enum role: [:manager, :employee, :guest]

  def can_create_project?
    manager? &&
      projects.count < Project.max_count &&
        redis.get('projects_creation_blocked') != '1'
  end

  private

  def redis
    Redis.current
  end
end

class ProjectsController < ApplicationController
  def create
    if current_user.can_create_project?
       @project = Project.create!(project_params)
       render json: @project, status: :created
    else
       head :unauthorized
    end
  end

  private

  def project_params
     params.require(:project).permit(:name, :description)
  end
end

在這種情況下,可以將這些規(guī)則抽取到一個Policy Object中,從而使Controller和Model同時瘦身。

class CreateProjectPolicy
  def initialize(user, redis_client)
    @user = user
    @redis_client = redis_client
  end

  def allowed?
    @user.manager? && below_project_limit && !project_creation_blocked
  end

 private

  def below_project_limit
    @user.projects.count < Project.max_count
  end

  def project_creation_blocked
    @redis_client.get('projects_creation_blocked') == '1'
  end
end

class ProjectsController < ApplicationController
  def create
    if policy.allowed?
       @project = Project.create!(project_params)
       render json: @project, status: :created
     else
       head :unauthorized
     end
  end

  private

  def project_params
     params.require(:project).permit(:name, :description)
  end

  def policy
     CreateProjectPolicy.new(current_user, redis)
  end

  def redis
     Redis.current
  end
end

def User < ActiveRecord::Base
   enum role: [:manager, :employee, :guest]
end

最終的結果是一個干凈的Controller和Model。Policy Object封裝了所有權限檢查邏輯,并且所有外部依賴都從Controller注入到Policy Object中。所有的類都各司其職。

7. Decorators

Decorator模式允許我們給某個類的實例添加各種輔助行為,而不影響相同類的其他實例。該設計模式廣泛用于在不同類之間劃分功能,也可以用來替代繼承以遵循單一原則。

示例

假設View中存在許多計算:

  • 根據(jù)title_for_head是否有值顯示不同的標題
  • 如果缺少車圖片,那么顯示一張默認的車圖片
  • 如果車的品牌、類型、說明、車主、城市和聯(lián)系電話未設置時,顯示默認值
  • 展示車的狀態(tài)
  • 顯示格式化后的車的創(chuàng)建日期

問題分析

View中包含了過多的計算邏輯:

#/app/controllers/cars_controller.rb
class CarsController < ApplicationController
 def show
   @car = Car.find(params[:id])
 end
end

#/app/views/cars/show.html.erb
<% content_for :header do %>
 <title>
   <% if @car.title_for_head %>
     <%="#{ @car.title_for_head } | #{t('beautiful_cars')}" %>
   <% else %>
     <%= t('beautiful_cars') %>
   <% end %>
 </title>
 <% if @car.description_for_head%>
   <meta name='description' content= "#{<%= @car.description_for_head %>}">
 <% end %>
<% end %>

<% if @car.image %>
 <%= image_tag @car.image.url %>
<% else %>
 <%= image_tag 'no-images.png'%>
<% end %>
<h1>
 <%= t('brand') %>
 <% if @car.brand %>
   <%= @car.brand %>
 <% else %>
    <%= t('undefined') %>
 <% end %>
</h1>

<p>
 <%= t('model') %>
 <% if @car.model %>
   <%= @car.model %>
 <% else %>
    <%= t('undefined') %>
 <% end %>
</p>

<p>
 <%= t('notes') %>
 <% if @car.notes %>
   <%= @car.notes %>
 <% else %>
    <%= t('undefined') %>
 <% end %>
</p>

<p>
 <%= t('owner') %>
 <% if @car.owner %>
   <%= @car.owner %>
 <% else %>
    <%= t('undefined') %>
 <% end %>
</p>

<p>
 <%= t('city') %>
 <% if @car.city %>
   <%= @car.city %>
 <% else %>
    <%= t('undefined') %>
 <% end %>
</p>
<p>
 <%= t('owner_phone') %>
 <% if @car.phone %>
   <%= @car.phone %>
 <% else %>
    <%= t('undefined') %>
 <% end %>
</p>

<p>
 <%= t('state') %>
 <% if @car.used %>
   <%= t('used') %>
 <% else %>
   <%= t('new') %>
 <% end %>
</p>

<p>
 <%= t('date') %>
 <%= @car.created_at.strftime("%B %e, %Y")%>
</p>

可以使用Draper這個裝飾gem,將所有邏輯抽取到CarDecorator中。

#/app/controllers/cars_controller.rb
class CarsController < ApplicationController
 def show
   @car = Car.find(params[:id]).decorate
 end
end

#/app/decorators/car_decorator.rb
class CarDecorator < Draper::Decorator
 delegate_all

 def meta_title
   result =
     if object.title_for_head
       "#{ object.title_for_head } | #{I18n.t('beautiful_cars')}"
     else
       t('beautiful_cars')
     end
   h.content_tag :title, result
 end

 def meta_description
   if object.description_for_head
     h.content_tag :meta, nil ,content: object.description_for_head
   end
 end

 def image
   result = object.image.url.present? ? object.image.url : 'no-images.png'
   h.image_tag result
 end

 def brand
   get_info object.brand
 end

 def model
   get_info object.model
 end

 def notes
   get_info object.notes
 end

 def owner
   get_info object.owner
 end

 def city
   get_info object.city
 end

 def owner_phone
   get_info object.phone
 end

 def state
   object.used ? I18n.t('used') : I18n.t('new')
 end

 def created_at
   object.created_at.strftime("%B %e, %Y")
 end

 private

 def get_info value
   value.present? ? value : t('undefined')
 end
end

改造后不包含任何計算的整潔View:

#/app/views/cars/show.html.erb
<% content_for :header do %>
 <%= @car.meta_title %>
 <%= @car.meta_description%>
<% end %>
?
<%= @car.image %>
<h1> <%= t('brand') %> <%= @car.brand %> </h1>
<p> <%= t('model') %> <%= @car.model %>  </p>
<p> <%= t('notes') %> <%= @car.notes %>  </p>
<p> <%= t('owner') %> <%= @car.owner %>  </p>
<p> <%= t('city') %> <%= @car.city %>    </p>
<p> <%= t('owner_phone') %> <%= @car.phone %> </p>
<p> <%= t('state') %> <%= @car.state %>   </p>
<p> <%= t('date') %> <%= @car.created_at%> </p>

總結

相信如上這些概念將有助于你了解在何時以及如何重構代碼。這些工具可以幫助你有效的管理代碼的復雜度。其實,在開發(fā)的最初就應該小心地規(guī)劃代碼邏輯的組織,這樣就可以避免之后在重構上花費大量時間。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

  • 項目到一段落了,終于將要完成1.X.X的最后版本即將進入2.0.1,但是看之前的項目全都是Controller(V...
    coderFamer閱讀 1,184評論 1 4
  • 前言 看了下上篇博客的發(fā)表時間到這篇博客,竟然過了11個月,罪過,罪過。這一年時間也是夠折騰的,年初離職跳槽到鵝廠...
    西木柚子閱讀 21,410評論 12 183
  • 介紹 objc.io objc.io 是關于 Objective-C 最佳實踐和先進技術的期刊,歡迎來到第一期! ...
    評評分分閱讀 1,811評論 5 24
  • Spring Cloud為開發(fā)人員提供了快速構建分布式系統(tǒng)中一些常見模式的工具(例如配置管理,服務發(fā)現(xiàn),斷路器,智...
    卡卡羅2017閱讀 136,506評論 19 139
  • 20161010 這兩天上班可能因為中午找到地方躺著休息了,感覺還可以,能夠忍受。 生過二胎的同事過來人和我普及了...
    蕭蕭依舊閱讀 430評論 0 0

友情鏈接更多精彩內容