Railsで開発していると親子/兄弟の関係にあるモデルをノードのように扱って辿りたくなる時がありますよね。
そんなときに使うと便利なVisitorパターンの説明、Rails版です。
プロジェクト管理ツールの開発を行っているとして、以下3つのモデルが親子関係になっているとします。
- プロジェクト (Project)
- タスク (Task)
- ステップ (Step)
- タスク (Task)
まずモデルでビジターを受け入れるモジュールを作ります。
models/concerns/visitor_acceptance.rb
module VisitorAcceptance
def accept(visitor)
visitor.visit(self)
end
end
各モデルはVisitorを受け入れるためにVisitorAcceptanceをincludeします。継承しているのがApplicationRecordなのはRails5をベースにしているためです。
models/project.rb
class Project < ApplicationRecord
has_many :tasks
include VisitorAcceptance
end
models/task.rb
class Task < ApplicationRecord
belongs_to :project
has_many :steps
include VisitorAcceptance
end
models/step.rb
class Step < ApplicationRecord
belongs_to :task
include VisitorAcceptance
def complete?
actual_hours > 0 && estimation_hours > 0
end
end
次にモデルを辿って処理をするVisitorの実装です。#visitで処理を振り分けます。
class ProjectVisitor
attr_reader :completion_steps, :estimation_hours, :actual_hours
def initialize
@completion_steps = 0
@estimation_hours = 0
@actual_hours = 0
end
def visit(object)
if object.is_a?(Task)
task(object)
elsif object.is_a?(Step)
step(object)
elsif object.is_a?(Project)
project(object)
end
end
private
def step(step)
@completion_steps += step.complete?? 1 : 0
@estimation_hours += step.estimation_hours
@actual_hours += step.complete?? step.actual_hours : 0
end
def task(task)
task.steps.each{|step|
step.accept(self)
}
end
def project(project)
project.tasks.each{|task|
task.accept(self)
}
end
end
最後に呼び出すときはModel#accept(visitor)を呼び出します。Project#acceptならtasks -> stepsと辿ります。Task#acceptならstepsを辿ります。
visitor = ProjectVisitor.new
project.accept(visitor) # task -> stepと辿る
p visitor.completion_steps
p visitor.estimation_hours
p visitor.actual_hours
visitor = ProjectVisitor.new
task.accept(visitor) # stepを辿る
p visitor.completion_steps
p visitor.estimation_hours
p visitor.actual_hours
モデルのデータ構造を辿って集計する処理がキレイにVisitorの中にまとまりましたね。
すっきり。
Visitorを使わないコードは以下のようになります。今回のように各ノードが一方向ならこれでも十分わかりやすいですね。
@completion_steps = 0
@estimation_hours = 0
@actual_hours = 0
project.tasks.each{|task|
task.steps.each{|step|
@completion_steps += step.complete?? 1 : 0
@estimation_hours += step.estimation_hours
@actual_hours += step.complete?? step.actual_hours : 0
}
}
ノードが循環したり入れ子になったりと複雑になって、それぞれで別の処理をするとなると、この記事のように独立したVisitorが欲しくなってきます。
質問などありましたらコメントかTwitter宛にお願いします。