hiyoko-programingの日記

プログラミングを勉強したてのひよっ子。   エンジニア目指して勉強中。

リファクタリングの応用 その2

リポジトリをクローン

ターミナル
1
2
$ git clone https://github.com/exp-drill/effective_model_sample.git
$ cd effective_model_sample

環境構築する

ターミナル
1
2
3
4
5
6
7
8
9
# クローンしたリポジトリに移動
$ cd effective_model_sample

# gemのインストール
$ bundle install

# DBの作成
$ rails db:create
$ rails db:migrate

ここまでコマンドを実行したら、rails sできるかどうか確認。
次のような画面が表示されれば、事前準備は完了。

https://tech-master.s3.amazonaws.com/uploads/curriculums//83c84e73ede08038d250eb139342dc85.png

リファクタリング実践

サンプルアプリの仕様確認

事前準備が終わったので、サンプルアプリの仕様を確認していく。

テーブル構造

サンプルアプリにはTasksテーブルが存在している。
テーブル構造は以下のようになっている。

保持している情報 カラム名 制約
タスクの名前 title string null: false
タスクの内容 content string なし
タスクの開始日程 start_at datetime null :false
タスクの終了日程 finish_at datetime null :false
タスクの種類 kind integer null: false
タスクが完了したかどうか finished boolean default: false

コントローラ

コントローラはTasksテーブルに対応したTasksコントローラのみ。
indexアクション、createアクション、updateアクションが存在している。

app/controllers/tasks_controller.rb
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class TasksController < ApplicationController

  def index
    @task = Task.new
    @tasks = Task.where('start_at > ?', Time.zone.now).order(start_at: :asc)
  end

  def create
    @task = Task.new(task_params)
    @task.save
    redirect_to tasks_path
  end

  def update
    @task = Task.find(params[:id])
    if @task.finished == false
      @task.finished = true
      @task.save
      redirect_to tasks_path
    else
      render :index, alert: '既にタスク「#{task.title}」は完了しています '
    end
  end

  private

  def task_params
    params.require(:task).permit(:title, :content, :start_at, :finish_at, :kind, :finished)
  end
end

ビュー

ビューはtasks/index.html.hamlがルートになる。

app/views/tasks/index.html.haml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
%nav.navbar.navbar-light{style: :"background-color: #e3f2fd;" }
  %a.navbar-brand 登録済みタスク一覧

= form_for @task, html: {class: 'form-group'} do |f|

  = f.label :kind
  = f.select :kind, [0, 1, 2], {}, class: 'form-control', placeholder: 0

  = f.label :title
  = f.text_field :title, class: 'form-control', placeholder: "ここにタスク名を入力してください"

  = f.label :content
  = f.text_area :content, class: 'form-control', placeholder: "ここにタスクの詳細を入力してください"

  = f.label :start_at
  = f.datetime_select :start_at, ampm: :true, minute_step: 15, class: 'form-control'

  = f.label :finish_at
  = f.datetime_select :finish_at, ampm: :true, minute_step: 15, class: 'form-control'

  = f.submit '作成する', class: "btn btn-primary"

.table-responsive
  %h4.r-title-label{style: 'display: inline-block; margin-right: 20px; '}
  %table.table.table-striped.b-t.b-light
    %thead
      %tr
        %th 種類
        %th タスクの名前
        %th タスクの内容
        %th 開始時間
        %th 終了時間
        %th 状態
    %tbody
      = render @tasks

ページ上部の フォームから入力された情報は、Tasksコントローラを通じてTasksテーブルに保存される。Tasksテーブルに登録されている各レコードは、部分テンプレートviews/_task.html.hamlを通じて描画されている。

app/views/tasks/_task.html.haml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
%tr
  %td
    - if task.kind == 0
      私用
    - if task.kind == 1
      仕事
    - if task.kind == 2
      その他
  %td
    = task.title
  %td
    = task.content
  %td
    = task.start_at
  %td
    = task.finish_at
  %td
    - if task.finished?
      完了済
    - else
      未完了
      %br
      = link_to "完了にする", task_path(task), method: :patch, class: "btn btn-primary "

現在のコードの問題点

ここまでご覧いただいたコードには、次のような問題がある。

(1)ビューにkindについてのロジックが記述されている

tasks/_task.html.hamlには、kindの値によって表示するビューを変更するif文が記述されている。ビューにif文を書いてしまうと、下記の理由によりコードの保守性を下げてしまう。

  • kindの0, 1, 2がそれぞれ何を意味するのか、ビューを見ないと分からない
  • kindの種類が増える度、新たにif文を足さなければいけない

(2)@tasksの定義が直観的に分かりづらい

Tasksコントローラのindexアクション内で定義されている変数@tasksの、.where('start_at > ?', Time.zone.now)という記述は、一目見ただけではどのような絞り込みを行っているのか分かりにくい。また、タスク管理アプリという性質上、現在時刻より開始時刻が先という絞り込み条件は、今後Taskモデル内部のメソッドや、他のコントローラなどでも再利用する可能性が高い。

絞り込みのロジックをTaskモデルに移動させて、メソッドのように名前をつけて、どこからでも再利用できるようにする必要がある。

(3)コントローラにロジックが記述されている

Tasksコントローラのupdateアクションは、特定のタスクのfinishedカラムの値をtrueにする処理を行っている。しかし、特定のカラムのみを更新する処理を表現するのに、Tasksコントローラのupdateアクションを利用してしまうと、updateアクションではTaskに関わる全てのカラムを更新していると、将来的に誤解を招く恐れがある。

現状、finishedカラムの他に更新したいカラムが存在しないなら、finishedを更新するためだけのコントローラを新たに作成するか、Taskモデルにインスタンスメソッドとして定義しておいて、コントローラで呼び出すように変更することで、Tasksコントローラの保守性・可読性を向上することができる。

enumを使ってkindの表示を綺麗にする

(1)ビューにkindについてのロジックが記述されているという問題は、enumを利用することで解決できる。

 enum

enumはint型、boolean型で定義されたカラムを、文字列で表現できるようにする機能。

1
2
3
4
5
class Drink < AppricationRecord
# Drinkモデルのkindrカラムのenumを定義する
# DBに保存されている値が0の時はtea, 1の時はcoffee、2の時はbeerを意味する
  enum kind: [:tea, :coffee, :beer]
end
1
2
3
4
@drink = Drink.new(kind: 1)
@drink.kind
# => 'coffee'
# 数字ではなくenumで定義したキーワードが返ってくる

enumを利用すると、数字と対応した文字列を返り値として出力できるようになる。
これを利用して、タスク管理アプリの(1)ビューにロジックが記述されているの問題を解決していく。

 Taskモデルにkindカラム用のenumを定義

app/models/task.rb
1
2
3
class Task < ApplicationRecord
  enum kind: [:individual, :work, :others]
end

部分テンプレートの記述を書き換え

app/views/tasks/_task.html.haml
1
2
3
4
5
6
7
/if文を削除し、task.kindと書き換える/
%tr
  %td
    = task.kind
  %td
    = task.title
/以下省略/

フォームの選択肢をenumを使って書き換え

enumを定義すると、[モデル名].[カラム名の複数形] のような形で、そのカラムに設定したenumを全て表示することができる。

1
2
Task.kinds
=> {"individual"=>0, "work"=>1, "others"=>2}

また、Task.kinds.keysのように記述することによって、enumで設定したkeyの一覧を得ることができる。

1
2
Task.kinds.keys
=> ["individual", "work", "others"]

これを入力フォームの f.select に引数として渡すことによって、選択肢を[0 ,1, 2]のようにベタ書きすることを避けられる。enumで対応しているkindが4、5と増えたとしても、フォームの選択肢を自動で増やせるメリットもあるので、このタイミングで書き換えてしまう。

app/views/tasks/index.html.haml
1
2
3
4
  = form_for @task, html: {class: 'form-group'} do |f|

    = f.label :kind
    = f.select :kind, Task.kinds.keys, {}, class: 'form-control', placeholder: 0

ここまで完了したら、改めて http://localhost:3000/ を開いて見る。

https://tech-master.s3.amazonaws.com/uploads/curriculums//4e1b579112b3a06321542cc474b4368d.png

「種類」の列に、「work」「individual」などと表示されているのが確認できる。enumを使用する前のビューでは、日本語を使用していたため、i18nを使って日本語化していく。この際、enum_help」というgemを利用すると、enumの日本語化が簡単に行える。

 enum_helpをインストール

Gemfile
1
2
# 末尾に追記
gem 'enum_help' 
ターミナル
1
$ bundle install

 ja.ymlとビューを編集して日本語化する

config/locales/ja.yml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# ja:の一行下から、enums: ~ others:までの記述を追加

ja:
  enums:
    task:
      kind:
        individual: "私用"
        work: "仕事"
        others: "その他"
  activerecord:
# 以下省略
app/views/tasks/_task.html.haml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
/ task.kindをtask.kind_i18nに書き換え /
%tr
  %td
    = task.kind_i18n
  %td
    = task.title
  %td
    = task.content
  %td
    = task.start_at
  %td
    = task.finish_at
  %td
    - if task.finished?
      完了済
    - else
      未完了
      %br
      = link_to "完了にする", task_path(task), method: :patch, class: "btn btn-primary "
app/views/tasks/index.html.haml
1
2
3
4
5
/ Task.kinds.keysをTask.kinds_i18n.invertで書き換え /
= form_for @task, html: {class: 'form-group'} do |f|

  = f.label :kind
  = f.select :kind, Task.kinds_i18n.invert, {}, class: 'form-control', placeholder: 0

enum_help を導入すると、 Task.kinds_i18n.invert のような書き方ができるようになるが、これは、各enumの日本語化後と元々の値をセットで返してくれるメソッド。

1
2
Task.kinds_i18n.invert
=> {"私用"=>"individual", "仕事"=>"work", "その他"=>"others"}

enum_help 導入後は、Task.kinds_i18n.invertと記述することによって、セレクトボックスの表示を日本語化することができる。

ここまでできたら、一度Raiilsサーバーを終了させた後、もう一度rails sを実行。これで、enumの日本語化が完了。

部分テンプレートapp/views/_task.html.hamlには、もう一箇所ロジックが書き込まれてしまっている箇所がある。それは、finishedの値がtrueかfalseかによって、「完了済」と「未完了」の表示を切り替えている部分である。 enum以外にも、「デコレーター」「プレゼンター」などを活用することで、ビューからロジックを切り離すことができる。余裕があれば、finishedについてもロジックの分離に挑戦してみると良い。

https://tech-master.s3.amazonaws.com/uploads/curriculums//38617296aaf9f18a08e16d41e7e89237.png

scopeを使って検索ロジックをモデルに移す

(2)@tasksの定義が冗長という問題は、モデルにscopeを定義することによって解決できる。

scope

scopeはモデルに対する絞り込みの条件に名前をつけて、メソッドのように呼び出し可能にする機能。よく使う検索ロジックはscopeにしておくことによって、後から簡単に再利用できるようになる。

それでは、scopeを活用して@tasksの定義を読みやすくしていく。

 Taskモデルにscopeを定義

今回は「incoming」という名前でscopeを定義する。

app/models/task.rb
1
2
3
4
5
class Task < ApplicationRecord
  enum kind: { individual: 0, work: 1, others: 2 }

  scope :incoming,  -> { where('start_at > ?', Time.zone.now) }
end

 定義したscopeを使って@tasksを再定義

app/controllers/tasks_controller.rb
1
2
3
4
5
6
7
8
9
class TasksController < ApplicationController

  def index
    @task = Task.new
    # モデルに定義したscopeはメソッドのように呼び出せる
    @tasks = Task.incoming.order(start_at: :asc)
  end

# 以下省略

@tasksをscopeを使った書き方に修正したら、もう一度 http://localhost:3000/ を開く。書き換え前と表示されているタスクの一覧が変わっていないことが確認できるはず。

実は、order(start_at: :asc)も、scopeとして定義して呼び出すことが可能である。

コントローラに書かれたロジックをモデルのメソッドで書き直す

最後に、(3)コントローラにロジックが記述されているの問題を解決していきましょう。Tasksコントローラのupdateアクション内の「finishedカラムをtrueにする処理」を、Taskモデルのインスタンスメソッドとしてmodels/task.rbに定義し直す。

 Taskモデルにインスタンスメソッドを定義

「finishedカラムをtrueに変更する」処理であることを分かりやすく伝えるために、update_finished_trueというメソッドを作成する。

app/models/task.rb
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Task < ApplicationRecord
  enum kind: { individual: 0, work: 1, others: 2 }

  scope :incoming,  -> { where('start_at > ?', Time.zone.now) }

  # finishedカラムをtrueに更新するメソッドを定義
  def update_finished_true
    self.finished = true if self.finished == false
    self.save
  end
end

 定義したインスタンスメソッドを利用してupdateアクションを書き直し

app/controllers/tasks_controller.rb
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class TasksController < ApplicationController

  def index
    @task = Task.new
    @tasks = Task.incoming.order(start_at: :asc)
  end

  def create
    @task = Task.new(task_params)
    @task.save
    redirect_to tasks_path
  end

  # 定義しておいたメソッドを呼び出す
  def update
    @task = Task.find(params[:id])
    @task.update_finished_true
    redirect_to tasks_path
  end

  private

  def task_params
    params.require(:task).permit(:title, :content, :start_at, :finish_at, :kind, :finished)
  end
end

コントローラ内に記述されていたロジックを、モデルのインスタンスメソッドとして切り出すことで、かなり行数を減らすことができた。また、元々の記述には存在していた「if文による条件分岐」がなくなっていることにも注目すると、元々は「finishedがtrueか、そうでないか」で更新を行うかどうかを決定していたが、「現状finishedがtrueのtaskについて、updateアクションを呼び出す導線がビューにないこと」 「元々値がtrueのカラムにtrueを入れても問題がないこと」を理由に、if文を削除している。

このように、ロジックを適切な箇所に移動させる過程で、不要な記述 が見つかることがある。こまめにコードを整備することによって、より見通しの良いコードを残すことができるので、「ちょっとこの記述読み辛くなってきたな」と感じたら、積極的にモデルを活用してリファクタリングを行う。

今回使用したリポジトリについて

今回クローンしたリポジトリの「refactored」というブランチでは、全ての修正が反映されている。復習などに利用できる。https://github.com/queq1890/effective_model_sample/tree/refactored

参考リンク

enumリファレンス(英語)
https://api.rubyonrails.org/v5.1/classes/ActiveRecord/Enum.html

enum_help Githubリポジトリ

https://github.com/zmbacker/enum_help

・ active_decorator Github リポジトリ
「デコレーター」を導入するためのgem。

https://github.com/amatsuda/active_decorator

Rails ModelのScope(スコープ)の使い方

https://ruby-rails.hatenadiary.com/entry/20140814/1407994568