重构 [译] 重构 Rails MVC 组件的 7 个设计模式

freefishz · November 30, 2016 · Last by davidzhu001 replied at August 23, 2021 · 13813 hits
Topic has been selected as the excellent topic by the admin.

原文请阅读 7 Design Patterns to Refactor MVC Components in Rails

MVC

  1. Service Objects (and Interactor Objects)
  2. Value Objects
  3. Form Objects
  4. Query Objects
  5. View Objects (Serializer/Presenter)
  6. Policy Objects
  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>

总结

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

👍 开始还以为是那篇老的 7 Patterns。举的例子都挺好。Interactor 那部分感觉有点多余,其实可以跟 Service Object 合并起来。如果遵从 DCI 的模式,也应该是 Interactor 里面调用 Context 的回调(虽然我不喜欢这种做法)。

请问 include Virtus.model 是什么?

#1 楼 @darkbaby123 其实我也不完全认同里面的观念,有些地方表面上看上去做到了分离,其实反而增加复杂性。具体应用还应该结合实际的情况做取舍,不过这些例子给了一些可以借鉴的思路。

感觉搞复杂了,不能完全认同。

这个重构厉害了 (太复杂了)

有几个确实搞得复杂了,感觉在炫技😅

有的还是很有用的

#6 楼 @kikyous 也没有炫技吧,逻辑/UI 复杂度到一定程度选择使用就好了,简单的没有必要。

jasl mark as excellent topic. 01 Dec 14:22

最早见于 Ruby Weekly,感谢分享翻译

presenter 那样做很蠢。。decorator 则会让命名空间更乱
article.present.meta_title这样是比较好的
类似的还有
product.update.available_stock product.calc.profit 把操作分发到 Updater 和 Calculator 里做

嗯嗯,重温了 j2ee 的感觉~,感觉不再是我写的 rails 那种轻便了。

很多都不能认同。

复杂与否需要看具体的场景。如果经常面对超过一千行的 model,就会发现这些绕来绕去的写法是多么好用。 文章中举的例子都是单场景。许多看起来之前复杂,重构后更复杂,这种感觉是对的。

但是有时候一个 model 牵连许多许多的业务逻辑的时候,二话不说全放在 model 里,这样做是 works 的,但是这么干简直就是犯罪。代码的可维护性,扩展性几乎没有。

想想这样的时候情况:一个订单模型作为入口同时涉及到产品,条码,六七种支付模式,折扣,优惠码,定制 (多糖少糖),会员卡,积分,余额,税款 (不同地方税制不同),货币,时区,退货,库存,店面,客户,取号预订,电话预订,桌台分布,打印小票,单据....

想想这些逻辑都在一次点餐消费的动作中执行并且由订单模型来统筹。

有时候不是为了炫技,而是切实的需要。

#14 楼 @suffering 确实,我现在就是这个问题,之前把订单相关的各种东西都由订单统筹,然后就是现在想改都不知道从哪下手

@suffering 说的很实际。如果坑。感觉优秀的架构概念是分清的。代码是理不清的

You need to Sign in before reply, if you don't have an account, please Sign up first.