オブジェクト向けRails


Primitive Obsession


次のコードを見たことがありますか.
# == Schema Information 
#
# Table name: sellers
#
# id :integer not null, primary key
# name :string
# role :string
# created_at :datetime not null
# updated_at :datetime not null
#
class Seller < ApplicationRecord
  def role_name
    if role == :admin
      "관리자"
    elsif role == :normal
      "일반 판매자"
    else
      "뉴비"
    end
  end

  def role_description
    if role == :admin
      "관리해요"
    elsif role == :normal
      "물건을 팔아요"
    else
      "새로 들어왔어요"
    end
  end

  def work?
    if role == :admin
      true
    elsif role == :normal
      false
    else
      false
    end
  end
end
すべてのコードがそうではないかもしれませんが、そのコードを見たことがあるかもしれません.以上のコードは冗長で読みにくい.また、新しい機能を追加する場合は、多くのif~else間を切り替える必要があります.どうやって直せるの?メソッドの名前role_namerole_descriptionにヒントがあります.

対象に向かって考える


RoleというPORO(Plain Old Ruby Object)を抽出します.以下の手順で作成します.
# app/models/seller/role.rb
class Seller::Role
  attr_reader :code, :name, :description

  class << self
    def all
      @all ||= [
        new(:admin, "관리자", "관리해요"),
        new(:normal, "일반 판매자", "물건을 팔아요"),
        new(:newbie, "뉴비", "새로 들어왔어요")
      ]
    end

    def of(code)
      if code.is_a?(String)
        code = code.to_sym
      end
      all.find { |role| role.code == code }
    end

    protected_methods :new
    
  end

  def initialize(code, name, description)
    @code = code
    @name = name
    @description = description
  end
end
さらに、Sellerでは、以下の変更が発生します.
class Seller < ApplicationRecord  
  def role
    @role_object ||= Seller::Role.of(super)
  end

  delegate :description, :name, to: :role, prefix: true
  

  # TODO
  def work?
    if role == :admin
      true
    elsif role == :normal
      false
    else
      false
    end
  end
end
delegateを使用すると、同じインタフェースを提供しながら、より凝集力のあるコードを作成できます.今、work?を再包装します.クラスは少し小さいもので実現できます.

より小さく割る


前の実施では、もう少し場所を分けてもらえますか?
# app/models/seller/role.rb
class Seller::Role
  attr_reader :code, :name, :description

  class << self
    def all
      @all ||= [
        new(:admin, "관리자", "관리해요"),
        new(:normal, "일반 판매자", "물건을 팔아요"),
        new(:newbie, "뉴비", "새로 들어왔어요")
      ]
    end
  end
  # ... 생략
end
次の部分をRoleのサブクラスにしてwork?を実現すればよい.
new(:admin, "관리자", "관리해요"),
new(:normal, "일반 판매자", "물건을 팔아요"),
new(:newbie, "뉴비", "새로 들어왔어요")
叶えてよ
# models/seller/role/admin.rb
class Seller::Role::Admin < Seller::Role
  def initialize
    super(:admin, "관리자", "관리해요")
  end

  def work?
    true
  end
end

# models/seller/role/newbie.rb
class Seller::Role::Newbie < Seller::Role
  def initialize
    super(:newbie, "뉴비", "처음이세요")
  end

  def work?
    false
  end
end

# models/seller/role/normal.rb
class Seller::Role::Normal < Seller::Role
  def initialize
    super(:normal, "일반 판매자", "물건을 팔아요")
  end

  def work?
    true
  end
end
現在、Seller::Roleでは、次のような変化が発生します.
class Seller::Role
  attr_reader :code, :name, :description

  class << self
    def all
      @all ||= [Seller::Role::Admin.new, Seller::Role::Normal.new, Seller::Role::Newbie.new]
    end
    # ...
  end
  # ...
end
次いで、Sellerからブランチを除去することができる.
class Seller < ApplicationRecord
  def role
    @role_object ||= Seller::Role.of(super)
  end

  delegate :description, :name, to: :role, prefix: true
  delegate :work?, to: :role
end
同じAPIを提供すると同時に、内部実装のみを変更できます.

ActiveRecordでのシーケンス化


Validationをよりスムーズにするために、ActiveRecord#serializeを使用します.数式図を参照して、次のインタフェースを提供します.
class Rot13JSON
  def self.rot13(string)
    string.tr("a-zA-Z", "n-za-mN-ZA-M")
  end

  # returns serialized string that will be stored in the database
  def self.dump(object)
    ActiveSupport::JSON.encode(object).rot13
  end

  # reverses the above, turning the serialized string from the database
  # back into its original value
  def self.load(string)
    ActiveSupport::JSON.decode(string.rot13)
  end
end
私たちのSeller::Role類は以下のように整理されています.
class Seller::Role
  attr_reader :code, :name, :description

  class << self
    # ...

    def load(code)
      Seller::Role.of(code)
    end

    def dump(role)
      role.code.to_s
    end
  end

  # ...
end
現在のSeller類は以下の通りです.
class Seller < ApplicationRecord
  serialize :role, Role

  delegate :description, :name, to: :role, prefix: true
  delegate :work?, to: :role
end
Validationを追加するには、次のようにします.

最終結果


Seller

class Seller < ApplicationRecord
  serialize :role, Role
  
  validates :role, inclusion: { in: Seller::Role.all }

  delegate :description, :name, to: :role, prefix: true
  delegate :work?, to: :role
end

Seller::Role

class Seller::Role
  attr_reader :code, :name, :description

  class << self
    def all
      @all ||= [Seller::Role::Admin.new, Seller::Role::Normal.new, Seller::Role::Newbie.new]
    end

    def of(code)
      if code.is_a? String
        code = code.to_sym
      end
      all.find { |role| role.code == code }
    end

    protected_methods :new

    def load(code)
      Seller::Role.of(code)
    end

    def dump(role)
      role.code.to_s
    end
  end

  def initialize(code, name, description)
    @code = code
    @name = name
    @description = description
  end
end

Seller::Role::{Admin, Normal, Newbie}

# models/seller/role/admin.rb
class Seller::Role::Admin < Seller::Role
  def initialize
    super(:admin, "관리자", "관리해요")
  end

  def work?
    true
  end
end

# models/seller/role/newbie.rb
class Seller::Role::Newbie < Seller::Role
  def initialize
    super(:newbie, "뉴비", "처음이세요")
  end

  def work?
    false
  end
end

# models/seller/role/normal.rb
class Seller::Role::Normal < Seller::Role
  def initialize
    super(:normal, "일반 판매자", "물건을 팔아요")
  end

  def work?
    true
  end
end
新しいroleを追加するのは簡単です.そしていくつかの凝集度の高い小さなクラスが得られた.元のクラスに比べてずっと小さくて、理解するのは負担がなくて、しかも私の変更の影響の地方はとても明確で、修正しやすいです.
また、ActiveRecordではなく、簡単なrubyobjectなので、テストはずっと簡単で、速度もずっと速いです.

参考資料

  • RailsConf 2019 - Refactoring Live: Primitive Obsession by James Dabbs
  • On Writing Software Well #4: Not every model is backed by a database
  • RailsConf 2014 - All the Little Things by Sandi Metz
  • エルレゴン対象
  • Refactoring GURU: Primitive Obsession
  • Refactoring: replace conditional with polymorphism