重构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 (如, 删除过期数据)
  • 使用多个model(如,从一个文件中导入数据到多个model)

示例

以下示例中,主要工作由外部Stripe服务完成。该服务基于邮件地址和来源创建Stripe客户,并将所有服务费用绑定到该客户的账号上。

问题分析

  • Controller中包含调用外部服务的代码
  • Controller负责构建调用外部服务所需的数据
  • 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来负责客户账号的创建和支付,从而解决了Controller中业务代码过多的问题。但是,还有一个问题需要解决。如果外部服务抛出异常时(如,信用卡无效)该如何处理,需要重定向的其他页面吗?

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,并捕获可能产生的异常。Interactor模式常用于封装业务逻辑,每个Interactor一般只描述一条业务逻辑。

Interactor模式通过简单Ruby对象(plain old Ruby objects, POROs)可以帮助我们实现单一原则(Single Responsibility Principle, SRP)。Interactor与Service Object类似,只是通常会返回执行状态及相关信息,而且一般会在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设计模式推崇简洁的对象(仅包含一些给定的值),并支持根据给定的逻辑,或基于指定的属性进行对象间相互比较(不基于id)。Value Object的例子如,以不同币种表示的货币。我们可以用一个币种(如,美元)来比较这些对象。同样,Value Object也可以用于表示温度,并可用单位开来进行比较。

示例

假设有一所带电加热的智能房子,加热器可以通过网络接口加以控制。Controller的一个方法将从温度传感器那里收到指定加热器的参数:温度数值和温度单位(华氏、摄氏或开)。如果是其他温度单位,一律先转换为开。然后,检查温度是否小于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只需要将参数传给`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瘦身,我们将创建Value Objects。Value Objects接受温度数值和温度单位作为初始化参数。在进行比较时,使用<=>操作符比较转换为开之后的温度。

同时,Value Object也包含一个to_h方法用于批量赋值。另外,还提供了工厂方法from_kelvinfrom_celsiusfrom_fahrenheit,便于以指定单位创建Temperature对象,如Temperature.from_celsius(0)将会创建一个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不包含任何与温度相关的业务逻辑,Model也不包含任何与温度转换相关的逻辑,仅调用了Temperature提供的方法。

3. Form Objects

Form Object模式适用于封装数据校验和持久化。

示例

假设我们有一个典型Rails Model和Controller用于创建新用户。

问题分析

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

现在,就可以在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不在负责校验数据:

class User < ActiveRecord::Base
end

4. Query Objects

该模式适用于从Controller和Model中抽取查询逻辑,并将它封装到可重用的类。

示例

假设我们请求一个文章列表,查询条件是类型为video、查看数大于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实现:

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

现在就可以使用这些简洁的API接口来查询,而不用关心底层是如何实现的。如果article的scheme发生了改变,仅需要修改article类即可。

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

看起来不错,不过又有一些新问题出现了。首先,需要为每个想要封装的查询条件创建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

哈,这样就可以做到重用了!现在可以将它用于查询任何具有相似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)"

现在,我们得到了一个封装所有查询逻辑,可重用,提供简洁接口并易于测试的类。

5. View Objects (Serializer/Presenter)

View Object适用于将View中的数据及相关计算从Controller和Model抽离出来,如一个网站的HTML页面或API终端请求的JSON响应。

示例

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

  • 根据服务器协议和图片路径创建图片URL
  • 获取文章的标题和描述,如果没有返回默认值
  • 连接姓和名来显示全名
  • 用合适的方式显示文章的创建日期

问题分析

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>

为了解决这个问题,可以先创建一个presenter基类,然后再创建一个ArticlePresenter类的实例。ArticlePresenter方法根据适当的计算返回想要的标签。

#/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

现在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模式适用于封装复杂的业务规则,并易于替换。比如,可以使用一个访客Policy Object来识别一个访客是否可以访问某些特定资源。当用户是管理员时,可以很方便的将访客Policy Object替换为包含管理员规则的管理员Policy Object。

示例

在用户创建一个项目之前,Controller将检查当前用户是否为管理者,是否有权限创建项目,当前用户项目数量是否小于最大值,以及在Redis中是否存在阻塞的项目创建。

问题分析

  • 自由Controller知道项目创建的规则
  • 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瘦身,可以将规则代码移到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

在这种情况下,可以将这些规则抽取到一个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中存在许多计算:

  • 根据title_for_head是否有值显示不同的标题
  • 如果缺少车图片,那么显示一张默认的车图片
  • 如果车的品牌、类型、说明、车主、城市和联系电话未设置时,显示默认值
  • 展示车的状态
  • 显示格式化后的车的创建日期

问题分析

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>

总结

相信如上这些概念将有助于你了解在何时以及如何重构代码。这些工具可以帮助你有效的管理代码的复杂度。其实,在开发的最初就应该小心地规划代码逻辑的组织,这样就可以避免之后在重构上花费大量时间。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268

推荐阅读更多精彩内容

  • 项目到一段落了,终于将要完成1.X.X的最后版本即将进入2.0.1,但是看之前的项目全都是Controller(V...
    coderFamer阅读 1,035评论 1 4
  • 前言 看了下上篇博客的发表时间到这篇博客,竟然过了11个月,罪过,罪过。这一年时间也是够折腾的,年初离职跳槽到鹅厂...
    西木柚子阅读 21,110评论 12 185
  • 介绍 objc.io objc.io 是关于 Objective-C 最佳实践和先进技术的期刊,欢迎来到第一期! ...
    评评分分阅读 1,649评论 5 24
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,099评论 18 139
  • 20161010 这两天上班可能因为中午找到地方躺着休息了,感觉还可以,能够忍受。 生过二胎的同事过来人和我普及了...
    萧萧依旧阅读 351评论 0 0