esm アジャイル事業部 開発者ブログ

永和システムマネジメント アジャイル事業部の開発者ブログです。

Railsでポリモーフィック関連を使った話(理由、必要な作業、注意点)

こんにちは、アジャイル事業部 9sako6 です。

私のいるプロジェクトで大きなエンハンスが行われ、その中で Polymorphic Association(ポリモーフィック関連) を使う場面がありました。 ポリモーフィック関連を選択した理由や行った作業、注意点について話します。

架空のサービスを例に説明を行います。

サービス概要

私たちが運営するサービスでは、Magician(魔法使い)と、その魔法使いが扱える Magic(魔法)を調べることができます。 この世界の Magic(魔法)に同一のものはなく、 ユニークです。Magic(魔法)はMagician(魔法使い)に属しています。

class Magic < ApplicationRecord
  belongs_to :magician
end

class Magician < ApplicationRecord
  has_many :magics
end

ところが、つい半年ほど前でしょうか、魔法を扱える動物 MagicAnimal(魔法動物)の存在が確認されました。 私たちは、魔法動物もサービスに登録できるようエンハンスを行いました。

私たちは、Magic(魔法)を Magician(魔法使い)と MagicAnimal(魔法動物)に従属させることにしました。ポリモーフィック関連付けを行ってできたモデルは以下です。

class Magic < ApplicationRecord
  belongs_to :magicable, polymorphic: true
end

class Magician < ApplicationRecord
  has_many :magics, as: :magicable
end

class MagicAnimal < ApplicationRecord
  has_many :magics, as: :magicable
end

これを実現するために以下のマイグレーションを行いました。

class AddMagicablePolymorphicColumnsToMagics < ActiveRecord::Migration[6.0]
  def up
    # `magician_id` -> `magicable_id`
    rename_column :magics, :magician_id, :magicable_id
    # Magician か MagicAnimal かを区別するカラム。
    # Magician なら 'Magician'、MagicAnimal なら 'MagicAnimal' を値としてもつ。
    add_column :magics, :magicable_type, :string

    # 既存レコードの `magicable_type` に値を入れる。
    Magic.reset_column_information
    Magic.update_all(magicable_type: 'Magician')

    change_column_null :magics, :magicable_type, false

    # `magicable_id` と `magicable_type` の複合インデックスを追加。
    add_index :magics, [:magicable_id, :magicable_type], options: :online
  end

  def down
    remove_index :magics, column: [:magicable_id, :magicable_type]

    remove_column :magics, :magicable_type
    rename_column :magics, :magicable_id, :magician_id
  end
end

ポリモーフィック関連を選択した理由

STI や delegated types、テーブル追加ではなくモリモーフィック関連にした理由を説明します。

既存への影響を小さくしたい

私たちが開発するサービスは Rails 4 の時代からスタートし、何年もの歴史を持っています。コードベースもデータ量も多く、なるべく修正コストが小さくなるようにしたいという気持ちがありました。

魔法を一覧表示したい

魔法使いと魔法動物のどちらに属するかを区別せず、魔法を一覧表示したい場面がありました。 そこではページネーションを Solr + Sunspot で行っており、1つのモデル、1つのテーブルの方が扱いやすいという前提があります。 したがって、Magic(魔法)を複数モデル、複数テーブルにすると処理が複雑になることが予想されました。

私たちのプロジェクトでは、既存影響なども考慮に入れると Magic(魔法)は1つのモデルにしておくメリットが大きく、STI や別テーブル追加にはしないことにしました。

Rails 6.1 から入った delegated type も一瞬検討候補に上がりましたが、私たちの Rails は 6.0.x だったので使えませんでした。

過去に開発ブログで delegated type について解説を行っているので、詳しく知りたい方はご覧ください。

blog.agile.esm.co.jp

DRY

Magic(魔法)は、Magician(魔法使い)から見ても MagicAnimal(魔法動物)から見ても本質的には同じものです。 同じものは1つのモデルで表すのが筋が良いと考えました。

Magic(魔法)に関しては DRY になるので、後々はメンテナンスが楽になると考えました。 もし新規モデルを追加し、魔法についてのコードが別れている状態になっていたら、片方に機能追加漏れやバグ修正漏れがないよう気を張りつめる必要があります。 開発メンバーは入れ替わっていくものですし、レビューのたびに気をつけなければならない観点は増やしたくありません。

Rails だから

ポリモーフィック関連を使用すると、外部キー制約による参照整合性が失われます。

しかし、Rails が提供する方法に則れば、アプリケーションのロジックがある程度リスクを低減してくれます。 今後 Rails から離れる予定もないため、ポリモーフィック関連を採用しました。

既存コードへの影響

ポリモーフィック関連付けにあたって必要だった修正について述べます。

なお、現在のところコードを自動で修正してくれる魔法は開発されていません。

joins, eager_load できない

ポリモーフィック関連にすると、以下のように joinseager_load できなくなります。

Magic.joins(:magicable)

# => Cannot eagerly load the polymorphic association :magicable (ActiveRecord::EagerLoadPolymorphicError)

クエリを工夫し、パフォーマンスに影響がないよう joinseager_load を使わないように修正する必要があります。

magician から magicable への書き換え

ポリモーフィック関連付けしたことにより、魔法テーブルは magician_id ではなく magicable_id を持つようになりました。

create_table "magics" do |t|
-   t.integer "magician_id", precision: 38, null: false
+   t.integer "magicable_id", precision: 38, null: false
+   t.string "magicable_type", null: false
end

従来 magician としていた箇所は、必要に応じて magicable に書き換えます。

- Magician.find(@magic.magician_id)
+ Magician.find(@magic.magicable_id)

- Magic.where(magician_id: current_magician.id)
+ Magic.where(magicable_id: current_magician.id, magicable_type: 'Magician')

- magician = magic.magician
+ magician = magic.magicable

Magician(魔法使い)と MagicAnimal(魔法動物)で同じ id をとりうる場合、magicable_type を漏れなく指定する必要があります。

こういった変更は、バッチ処理用のスクリプトや管理画面 (RailsAdmin 製)にも及びました。

Solr インデックス

プロジェクトでは、全文検索に Solr が使われています。 先ほどと似た話で、もともと magician_id を渡していた箇所を magicable_id に修正する必要があります。

integer :magician_id, as: 'Index.magician_id' do
-   magic.magician_id
+   magic.magicable_id
end

しかし、MagicAnimal(魔法動物)も magicable_id で関連付いているので、上記の修正により検索対象が変わってしまいます。 ここでは Magician(魔法使い)だけを対象としたいです。

他の手として、インデックス名 Index.magician_idIndex.magicable_id に変更し、Index.magicable_type インデックスを追加する方法があります。綺麗な姿に見えますが、実現するためには既存レコードの再インデックスが必要です。 再インデックス処理には長い時間がかかるため、現実的ではありませんでした。1

そこで、magicable_type で分岐して Solr に渡す値を変えることにしました。

integer :magician_id, as: 'Index.magician_id' do
-   magic.magician_id
+   magic.magicable_id if magic.magicable_type == 'Magician'
end

上記のように magicable_type で分岐させることで、既存のインデックス済みのレコードに影響を与えずに Solr を使うことができました。

バリデーションの場合分け

Magician(魔法使い)が使う魔法には、必ず rank(階級)が定められています。Magic(魔法)はこの rank カラムを持っていますが、MagicAnimal(魔法動物)の扱う魔法には階級がありません。

Magician(魔法使い)がもつ Magic(魔法)のときだけ rank にバリデーションをかけたいです。

enumerize :rank, in: MAGIC_RANKS, predicates: true
- validates :rank, presence: true
+ validates :rank, presence: true, if -> { magicable_type == 'Magician' }

このように片方に必要で片方に必要ないカラムがあるなら、バリデーションを分ける必要があります。

そして、Magician(魔法使い)と MagicAnimal(魔法動物)のための別々のカラムが増えるほど、Magic(魔法)のバリデーションは複雑になっていきます。 実際に、開発途中で魔法動物に関する法律が制定されたことで要求に変化があり、 MagicAnimal(魔法動物)にだけ必要なカラムが増えてしまいました。 結果、バリデーションは結構複雑なことになりました。これは仕方がないので受け入れています。

View での分岐

ある場面では、magicable が Magician か MagicAnimal か判定するために分岐が増えてしまうかもしれません。

例えば、Magician#employed? は、魔法使いが雇われているかそうでないかを返す predicate method です。魔法動物には雇用の概念がないので、employed? は実装されていません。

これまで、雇われ魔法使いなら雇用企業の情報を、非雇われ魔法使いなら卒業学校の情報を表示していました。 しかし、魔法動物は働かないし学校に行ったことがないので、魔法使いにだけ employed? を呼び出す必要があります。

- - if @magic.magician.employed?
-   = render 'company', magician: @magic.magician
- - else
-   = render 'school', magician: @magic.magician
+ - if @magic.magicable_type == 'Magician'
+   - if @magic.magicable.employed?
+     = render 'company', magician: @magic.magicable
+   - else
+     = render 'school', magician: @magic.magicable

リリース後の話

リリースして様子を見ていたところ、パフォーマンスリグレッションが起きました。 インデックスの貼り方に問題があったのです。

magicable_type の漏れや、複合インデックスの順番ミスによってインデックスが効いていませんでした。 すぐに修正し、その日のうちに問題は解決されました。

- t.index ["magicable_id", "name"], ...
+ t.index ["magicable_id", "magicable_type", "name"], ...
- t.index ["magicable_type", "magicable_id"], ...
+ t.index ["magicable_id", "magicable_type"], ...

それ以外は大きな問題なく、リリース後Nヶ月月経った今でもきちんと動いています。

さいごに

本記事では、ポリモーフィック関連を採用するに至った理由や、ポリモーフィック関連付けにするための修正について書きました。 もし同様の問題解決に迫られた時、本記事で述べた観点が技術選択のヒントになると幸いです。

最後に、永和システムマネジメント アジャイル事業部では Web アプリケーションを開発する仲間を募集しています。 Ruby や Rails に馴染みがあったり、会社に興味をもっていただけた方はぜひご応募ください!

agile.esm.co.jp


  1. 再インデックスする場合は、バッチ処理で行います。対象件数を絞って再インデックスする処理を繰り返し、何日もかけて処理が完了します。今回はリリースタイミングの都合などで採用できない案でした。

Rails / OSS パッチ会オンライン 2021年8月のお知らせ

2021年8月の Rails / OSS パッチ会を 8月26日(木)にオンライン開催します。

この会をひとことでいうと、日頃のお仕事で使っている Rails をはじめとする OSS への upstream にパッチを送る会です。

会には Ruby と Rails のコミッターである顧問の a_matsuda もいますので、例えば Rails に送るパッチのネタがあるけれど、パッチを送るに適しているかの判断やパッチを送る流れが悩ましいときなど a_matsuda に相談して足がかりにするなどできます。

開催時間は 17:00-19:00 となりますがご都合のあう方はぜひご参加下さい。Zoom あたりのテレビ会議システムを使います。

当日の招待 URL は Idobata の esminc/rails ルームで共有する予定です。

idobata.io

特に募集ページなど設けませんが、上記理由からすでに Idobata のアカウントを持っている方は、当日の案内を Idobata にてご確認ください。 また Idobata はクローズ化されているため Idobata アカウントを持っていない参加希望の方は、@koic までメンションしてください。

パッチ会では、来月開催の RubyKaigi Takeout 2021 に関する話題などあるかもしれません。

その他の開催方針については以下の Gist に記していますので、ご参照ください。

Reboot Rails/OSS meetup online · GitHub

RubyKaigi Takeout 2021 に永和システムマネジメントから @koic と @ima1zumi が登壇します

2021年9月9日(木) から11日(土) の3日間にわたって開催される RubyKaigi Takeout 2021 に永和システムマネジメントから @koic (Day 1) と @ima1zumi (Day 3) が登壇します。

rubykaigi.org

ここでは、それぞれの登壇スケジュールと講演内容について軽く紹介します。

9月9日(木) 11:30-11:55 @koic 『RuboCop in 2021: Stable and Beyond』

2020年10月に RuboCop 1.0 がリリースされておおよそ1年が経過しようとしています。

前回の RubyKaigi Takeout 2020 では日々の OSS 活動のうち、RuboCop 1.0 への道のりからテーマを抽出したことに対して、今年は RuboCop 1.0 以降の RuboCop 開発で行っていることからテーマを抽出しました。

RuboCop 1.0 は安定したメジャーバージョンということで、Breaking Change を起こさないアップグレードを指向しています。この講演では、アップグレードのメリットや、その舞台裏における問題抽出の環境について紹介する予定です。また、現状の RuboCop の静的解析における限界に対する設計や実装ポイントについても取りあげつつ、今後の拡張に対するアイデアのいくつかをお伝えします。

RuboCop のライトユーザーから、カスタム cop を作っている RuboCop マニアまで何かしら Takeout してもらえると良いなといったコンテンツ作成を進めています。お楽しみに。

9月11日(土) 11:00-11:25 @ima1zumi 『Dive into Encoding』

文字コードとは何でしょうか。私は現実世界の "文字" を抽象化して計算機上で扱えるようにしたものだと思います。業務アプリケーションで現実世界の業務を抽象化してコードを書くように、文字コードもまた "文字" を抽象化して計算機上で扱えるようにしているのだと考えています。

Ruby で 'あ' というコードを書くと String のインスタンスが作られます。例えば 'あ'.bytes を実行すると という文字のバイト列が得られます。 私は「あ」という文字がバイト列になっているということを想うととても興味深く感じます。その間には一体どんなコードがあり、文字はバイト列になるのでしょうか?また、バイト列で表現された文字と文字コードとの関係、文字コードを変換するとは一体どういう意味なのでしょうか。Ruby は String のインスタンスそれぞれが文字コードを持っていますが、それはどういうことなのでしょう?

これらの疑問を解決するため、自作文字コード「いろは」を作ってローカルの Ruby でビルドし、その過程を追うことで Ruby の文字コードの取り扱いについて深く潜ってみることにしました。このセッションでは、文字コードの基礎から Ruby の文字コードの実装まで話します。これを機に文字コードの世界に興味を持っていただければ幸いです。


それでは、オンライン会場でお会いしましょう。

RubyKaigi Takeout 2021 に Platinum スポンサーとしても参加している永和システムマネジメントでは、Ruby とアジャイルソフトウェア開発を通じてコミュニティと成長したいエンジニアを絶賛募集しています。

agile.esm.co.jp

XP祭り 2021 に永和システムマネジメントから8名が登壇します

2021年9月18日(土) にオンライン開催される XP祭り 2021 に、弊社CEO @hiranabe の基調講演はじめ、永和システムマネジメントから8名が登壇します。

弊社メンバーの登壇スケジュールとタイトルは以下です。

お時間のあう方はぜひ遊びに来てください。

xpjug.connpass.com


株式会社永和システムマネジメントでは、Ruby とアジャイルソフトウェア開発を通じてコミュニティと共生しながら成長したいエンジニアを絶賛募集しています。

agile.esm.co.jp

RubyKaigi Takeout 2021 に Platinum スポンサーとして協賛します

こんにちは、 @yucao24hours です。

私たち永和システムマネジメントは、2021 年 9 月 9 日・10日・11 日の 3 日間に渡って開催される RubyKaigi Takeout 2021 に Platinum スポンサーとして協賛することとなりましたのでお知らせします。

rubykaigi.org

新型コロナウィルスの猛威が依然として収まらぬ中、多くの困難を乗り越えてこのような素晴らしいカンファレンスの場を今年も作っていただけたことを大変ありがたく思っています。

状況の改善を願い "RubyKaigi" として一同に集まってのカンファレンス開催を企画してくださっていた関係者のみなさまの胸中は察するに余りあります。そんな大変な中でありながらも、素晴らしいスピーカーの方々をお迎えいただき開催されるカンファレンスを、参加者として今からとても楽しみにしています。


弊社は前回までの RubyKaigi でも、さまざまな形のスポンサーとして開催のお力添えをさせていただいてきました。

今回の RubyKaigi Takeout も、関わるみなさまにとってより充実した場となり、ひいては Ruby をとりまくコミュニティ全体が活気に溢れた場であり続けられるようにという想いを込めて、微力ながらサポートさせていただきます。

なお、Platinum スポンサー特典として、幕間後に CM を流させていただけることとなりました。現在、オリジナル CM を鋭意準備中です!ぜひお楽しみに。

ぼくがかんがえたさいきょうの Input object

こんにちは。最近筋トレにはまっている wat-aro です。

blog.agile.esm.co.jp で Input object が紹介されていますが、実際に使っていくとネストしたパラメータの扱いに困ったためその解決方法を紹介します。
この記事のコードはすべて https://github.com/wat-aro/input_object にあります。

ここでは題材として GitHub REST API の

POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews

を扱います。
API の定義は以下です。
https://docs.github.com/ja/rest/reference/pulls#create-a-review-for-a-pull-request

type RequestBody = {
  commit_id?: string;
  body?: string;
  event?: string;
  comments?: Array<{
    path: string;
    position?: number;
    body: string;
    line?: number;
    side?: string;
    start_line?: number;
    start_side?: string
  }>
}

ネストしたパラメータ

題材にしたAPI定義の comments のようにリクエストパラメータがネストしている場合がよくあります。
Input object で API のリクエストパラメータを受け取るようにしていると、このネストしたパラメータの扱いに困ります。
先の記事に紹介されたように、 AtiveModel::Attributes API を使うと ActiveRecord と同じようにキャストした値が扱えるのですが、 ActiveModel::Type にはネストしたパラメータを扱えるようなものがありません。

class ReviewInput
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :commit_id, :string
  attribute :body, :string
  attribute :event, :string, default: 'COMMENT'
  # attribute :comments, ここでネストしたパラメータを扱いたい
end

これを解決するためにハッシュや配列から別オブジェクトにキャストできるクラスを作成します。
まずは comment 用の Input object を用意します。

class Review::CommentInput
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :path, :string
  attribute :position, :integer
  attribute :body, :string
  attribute :line, :integer
  attribute :side, :string
  attribute :start_line, :integer
  attribute :start_side, :string
end

この Review::CommentInputReviewInputattribute に指定できるようにします。
Input クラスを引数にとって initialize できる ActiveModel::Type を作成します。

class InputType < ActiveModel::Type::Value
  def initialize(input_class, array: false)
    @input_class = input_class
    @array = array
  end

  def type
    :"#{@input_class.to_s.tableize.singularize}"
  end

  private

  def cast_value(value)
    if array?
      if value.is_a?(Array)
        value.map {|v| to_input(v) }
      else
        []
      end
    else
      to_input(value)
    end
  end

  def array?
    @array
  end

  def to_input(value)
    @input_class.new(value)
  rescue ArgumentError
    nil
  end
end

InputType を使うと Review::CommentInputattribute で指定できるようになります。

  attribute :comments, InputType.new(Review::CommentInput, array: true), default: []

これでネストしたパラメータや配列になっているパラメータを Input object として扱えるようになりました。

ネストしたパラメータのバリデーション

ネストしたパラメータを扱えるようになりましたが、今のままだとバリデーションが少し不便です。
ReviewInput#valid? を呼び出してもネストした comments のバリデーションが実行されません。
バリデーションメソッドを使って comments のバリデーションを実行したとしても、そのままでは ReviewInput#errors に追加されませんし、どのフィールドでエラーがでているかを翻訳できる形で表示するには一工夫必要です。

ここではカスタムバリデータを作成してこの問題を解決します。
このカスタムバリデータは以下のように書いて呼び出します。

  validates :comments, input: true

これは、comments に対して valid? を呼び出し、errors に翻訳できる形でエラーを追加します。

class InputValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, input)
    if input.respond_to?(:each)
      input.each do |i|
        validate_input(record, attribute, i)
      end
    else
      validate_input(record, attribute, input)
    end
  end

  private

  def validate_input(record, attribute, input)
    if input.respond_to?(:valid?)
      return if input.valid?

      input.errors.messages.each do |key, values|
        values.each do |value|
          record.errors.add("#{record.class.to_s.tableize.singularize}/#{attribute}.#{key}", value)
        end
      end
    else
      record.errors.add(attribute, :invalid)
    end
  end
end

翻訳ファイルについては

---
ja:
  activemodel:
    attributes:
      'review_input/comments':
        body: コメントの本文
        path: コメントのパス

のようにしておくとエラーがあった場合に

irb(#<Repositories::PullRequests::ReviewsController:0x00005634fe0e8a38>):003:0> e.record.errors.messages
=> {:"review_input/comments.body"=>["を入力してください"]}
irb(#<Repositories::PullRequests::ReviewsController:0x00005634fe0e8a38>):004:0> e.record.errors.full_messages
=> ["コメントの本文を入力してください"]

のようにエラーメッセージを出せるようになります。

さいごに

Input object を実際に扱う上で困るネストしたパラメータに対応する方法を紹介しました。
この記事がいいと思った方はチャンネル登録とグッドボタンをよろしくおねがいします。

agile.esm.co.jp

Rails / OSS パッチ会オンライン 2021年7月のお知らせ

2021年7月の Rails / OSS パッチ会を 7月29日(木)にオンライン開催します。

この会をひとことでいうと、日頃のお仕事で使っている Rails をはじめとする OSS への upstream にパッチを送る会です。

会には Ruby と Rails のコミッターである顧問の a_matsuda もいますので、例えば Rails に送るパッチのネタがあるけれど、パッチを送るに適しているかの判断やパッチを送る流れが悩ましいときなど a_matsuda に相談して足がかりにするなどできます。

開催時間は 17:00-19:00 となりますがご都合のあう方はぜひご参加下さい。Zoom あたりのテレビ会議システムを使います。

当日の招待 URL は Idobata の esminc/rails ルームで共有する予定です。

idobata.io

特に募集ページなど設けませんが、上記理由から Idobata のアカウントが必要になると思います。

翌 7月30日(金) 締め切りの Kaigi on Rails 2021 の CFP や、最近の Ruby / Rails まわりの動向に関する話題などあるかもしれません。

その他の開催方針については以下の Gist に記していますので、ご参照ください。

Reboot Rails/OSS meetup online · GitHub