概要
コピペコードが増えがちなサンプルアプリケーションの設計を例にとって、
STI(単一テーブル継承)とメタプログラミングでDRY(重複排除)してみる。
題材
ユーザが保持している楽曲をジャンルごとに管理するようなアプリケーション。
ユーザページでは、ジャンル別に登録曲を一覧(もっと言うとCRUD)できる。
こんなイメージですね。
kidachi_さん
あなたの登録曲一覧
Rock
ほげRock
ふがRock
Pops
未登録です。
Jazz
ふーJazz
ばーJazz
何も考えないで作ると、rock/pops/jazzそれぞれのモデル、ビュー、コントローラに
似たような記述・コピペが増えそうな予感を感じて頂けたでしょうか。
では、それを防ぐために、まずはSTIから。
(※追記)
実は上記だけの要件であれば
userテーブル、musicテーブル、genreテーブルのみを用意して
user has_many genres through musics
のassosiationでも実現可能
(そもそもrock/pops/jazzモデルを用意する必要がない)だったりします。
実際は「今後それぞれのgenreごとに特有な処理を複数追加していきたい」というケースを想定して、
各genre個別のモデルを用意することを前提にしています。
ちょっと要件の例がいまいちだったかもしれず、申し訳ありません。。
STI(Single Table Inheritance/単一テーブル継承)とは
「継承」と言えば、各クラスの共通項目が括りだされた
スーパークラスと、非共通項目のサブクラスを用いることで
コードの重複を防ぐ仕組みですが、それを
db(テーブル)設計にも適用しようというのがSTIです。
(単一テーブル継承、という名前そのままですね。)
具体例
先ほどの楽曲管理アプリのテーブルをざっくり設計してみると、
以下のようなイメージになると思います。
悪くはないのですが、モデル/テーブルに重複情報が
増えてしまいそうですね。
そこで単一テーブル継承
こうなります。
スーパークラスを用意して継承の仕組みを使うのと概念は全く一緒です。
一見どうということも無い様に見えますが、実は
- rock、pops、jazz(etc)テーブルは実在しない
- 全てのデータは、musicテーブルに保管される
という特徴を持ちます。
さりげなくmusicテーブルに「type」というカラムが追加されていますが、
ここにrock/pops/jazzといった子の情報が入る事になります。
また、もしrockテーブルにしか持たないような情報が欲しい場合は、
事前にそれはmusicに定義しておきます。
Modelの実装
STIを使っても、モデルは通常の継承の形で実装するだけです。
class Music < ActiveRecord::Base
belongs_to :user
validates :name, presence: true
validates :artist, presence: true
end
class Rock < Music
end
気をつけるのは、
子が継承するのはActiveRecord::Baseでなく親であるMusic
というところくらいでしょうか。
ちなみに、親で設定したリレーションやvalidatesは全て
子クラスにも引き継がれます。
STIについてはここまで。
結論、STIを利用するには、db生成時に
- 親テーブル(今回はmusic)にtypeカラムを持たせる。
- 子の一つに特有なカラムが必要であれば、それも親に持たせておく
- (あとは普通に継承の形でモデルを作る)
だけで良いことなります。
Cntrollerの実装とメタプログラミング
users/showで該当ユーザの楽曲一覧を表示したい、という仕様でしたね。
つまりshowで@rock/@pops/@jazzの3つをセットしておきたいということ。
ここでメタプログラミングを利用してみます。
class UsersController < ApplicationController
GENRE = [
'rock',
'pops',
'jazz'
]
def show
@user = User.find(current_user.id)
set_music_by_genre
end
private
def set_music_by_genre
@music_list = Array.new
GENRE.each do |music|
key = "@#{music}"
if @user.send(music).nil? # 1
music = music.gsub(/\b\w/) { |s| s.upcase } # 2
value = self.class.const_get(music).new # 3
else
value = @user.send(music) # 4
end
instance_variable_set(key, value) # 5
@music_list << instance_variable_get(key) # 6
end
end
end
set_music_by_genreの説明
####1. if @user.send(music).nil?
sendを用いることで、@userに対するメッセージングを動的に行っています。
つまり、@user.rock/@user.pops/@user.jazzを動的に生成しているということ。
ここでは、@userがrock/pops/jazzそれぞれのデータを持っているかどうか
を判別し、既存データを保持している場合はそれを呼び出し(#2,3)、
保持していなければ新規オブジェクトを生成(#4)します。
####2. music.gsub(/\b\w/) { |s| s.upcase }
"Rock"/"Pops"/"Jazz"という文字列を動的に生成。
#3で使います。
####3. self.class.const_get(music).new
self.class.const_get(str)で、文字列(str)から
クラスやモジュールの定数を取得できます。
つまり、#2で生成した"Rock"/"Pops"/"Jazz"を
クラス(Rock/Pops/Jazz)に変換し、それぞれnewしています。
####4. @user.send(music)
#1と同様で、@userに対して動的にメッセージを送っています。
####5. instance_variable_set(key, value)
動的にインスタンス変数を生成。
@rock/@pops/@jazzそれぞれに、既存オブジェクトもしくは新規オブジェクトをセットします。
####6. @media_list << instance_variable_get(key)
viewで用いるために、
[
#<Rock id: nil, type: "Rock", created_at: nil, updated_at: nil>,
#<Pops id: nil, type: "Pops", created_at: nil, updated_at: nil>,
#<Jazz id: nil, type: "Jazz", created_at: nil, updated_at: nil>
]
このようなオブジェクトがセットされた配列を用意します。
View
<% @music_list.each do |music| %>
<% if music.try(:id).nil? %>
<!-- 新規登録フォーム -->
<% else %>
<!-- 登録済み情報の表示 -->
<% end %>
<% end %>
showでセットした@music_listをもとに、instance_variable_get()で
動的にオブジェクトを取得し、それに応じて表示処理を行います。
Controller
新規登録フォームから投げられたデータをもとにcreateする箇所。
ここでもself.class.const_get()でサブクラスに応じた
クラス(Rock/Pops/Jazz)を動的にセットし、保存しています。
class MusicController < ApplicationController
before_action :set_music_info
before_action :music_params
def create
const_name = @music_name.gsub(/\b\w/) { |s| s.upcase }
# サブクラスごとのオブジェクトを初期化
@music = self.class.const_get(const_name)
@music.new(music_params)
respond_to do |format|
if @music.save!
~
end
end
end
private
# strong_parameters
def music_params
params.require(@music_name).permit(:user_id, :name, :email, :password)
end
end
class RockController < MusicController
private
set_music_info
@music_name = "rock"
end
end
STIを用いてmusicテーブルが用意されているためか、music_controllerで
各サブクラス(rock/pops/jazz)のsave処理を行うのもごく直感的に行えます。
※個人的には、STIは特別な仕組みというより
継承関係を持たせたいmodelに対して(直感的に)適合するテーブルのインターフェース
なんじゃないかな、と思っています。
完成
さて、これでshowページでの楽曲一覧と、楽曲新規登録のロジックができました。
このようなメタな骨組みを用意することで、musicのジャンルを増やす際は
hoge_controller.rbとGENREリストに新たなジャンルを追加するだけで良いことになります。
(もちろんcontroller/modelファイルを用意したりroutes.rb追記したり、は別途必要ですが)
class ClassicController < MusicController
private
set_music_info
@music_name = "classic"
end
end
class UsersController < ApplicationController
GENRE = [
'rock',
'pops',
'jazz',
'classic'
]
※GENRE配列は設定ファイルとして外部化した方が良いですね
こんな感じ。
長くなるのでここまでにしますが、メタプログラミングを使うことで
他のロジック部分も同様にコピペコードを撲滅できそうです。
参考
Railsで単一テーブル継承(Single Table Inheritance)
http://blog.matake.jp/archives/railssingle_table_inherit
const_get (Module)
http://ref.xaio.jp/ruby/classes/module/const_get
instance_variable_set/get (Object)
http://ref.xaio.jp/ruby/classes/object/instance_variable_set
http://ref.xaio.jp/ruby/classes/object/instance_variable_get
最後に
もっと効率の良い設計/書き方があれば、ぜひご教示よろしくお願い致します!