LoginSignup
258
223

More than 5 years have passed since last update.

[Rails] STI(単一テーブル継承)とメタプログラミングでDRY

Last updated at Posted at 2013-12-24

概要

コピペコードが増えがちなサンプルアプリケーションの設計を例にとって、
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です。
(単一テーブル継承、という名前そのままですね。)

具体例

先ほどの楽曲管理アプリのテーブルをざっくり設計してみると、
以下のようなイメージになると思います。

music1.png

悪くはないのですが、モデル/テーブルに重複情報が
増えてしまいそうですね。

そこで単一テーブル継承

こうなります。

music2.png

スーパークラスを用意して継承の仕組みを使うのと概念は全く一緒です。

一見どうということも無い様に見えますが、実は

  • rock、pops、jazz(etc)テーブルは実在しない
  • 全てのデータは、musicテーブルに保管される

という特徴を持ちます。

さりげなくmusicテーブルに「type」というカラムが追加されていますが、
ここにrock/pops/jazzといった子の情報が入る事になります。

また、もしrockテーブルにしか持たないような情報が欲しい場合は、
事前にそれはmusicに定義しておきます。

Modelの実装

STIを使っても、モデルは通常の継承の形で実装するだけです。

music.rb
class Music < ActiveRecord::Base
  belongs_to :user
  validates :name, presence: true
  validates :artist, presence: true
end
rock.rb
class Rock < Music
end

気をつけるのは、
子が継承するのはActiveRecord::Baseでなく親であるMusic
というところくらいでしょうか。

ちなみに、親で設定したリレーションやvalidatesは全て
子クラスにも引き継がれます。

STIについてはここまで。

結論、STIを利用するには、db生成時に

  • 親テーブル(今回はmusic)にtypeカラムを持たせる。
  • 子の一つに特有なカラムが必要であれば、それも親に持たせておく
  • (あとは普通に継承の形でモデルを作る)

だけで良いことなります。

Cntrollerの実装とメタプログラミング

users/showで該当ユーザの楽曲一覧を表示したい、という仕様でしたね。
つまりshowで@rock/@pops/@jazzの3つをセットしておきたいということ。

ここでメタプログラミングを利用してみます。

users_controller.rb
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

views/users/show.html.erb
  <% @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)を動的にセットし、保存しています。

music_controller.rb
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

rock_controller.rb
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追記したり、は別途必要ですが)

classic_controller.rb
class ClassicController < MusicController
  private
    set_music_info
      @music_name = "classic"
    end
end
users_controller.rb
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

最後に

もっと効率の良い設計/書き方があれば、ぜひご教示よろしくお願い致します!

258
223
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
258
223