ユーザーへの入力を便利にしようと、ウィザード形式を採用しようと思って、試しに雑に作ってみた時にハマってしまった。
例
実際のコードは晒せないので、サンプルとして以下のような状況を考える。
例えば、プロフィールサービスを作っていて、ユーザー登録後にユーザーのニックネームやアバター画像、TwitterやFacebookなどSNSのアカウント情報を入力させたくて、ウィザード形式を採用しようとしている。とする。
ここで、プロフィール入力を2ステップで構成されるウィザード形式にしたい。
- ニックネームと誕生日を入力
- 各SNSのプロフィールのURL
また、各プロフィールは必須入力としたい。
当初はウィザード形式は考慮していない実装だったので、ユーザー登録後に入力する情報は、いずれもユーザーのプロフィールということでprofiles
テーブルに登録する。
テーブルのスキーマは以下のようになる。
ActiveRecord::Schema.define(version: 2019_08_11_000716) do
create_table "profiles", force: :cascade do |t|
t.string "nickname"
t.date "birthday"
t.string "twitter_profile_url"
t.string "facebook_profile_url"
t.integer "user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id"], name: "index_profiles_on_user_id"
end
create_table "users", force: :cascade do |t|
t.string "email"
t.string "password_digest"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["email"], name: "index_users_on_email", unique: true
end
end
良くない実装
必須入力の項目のチェックするために、Profile
モデルにバリデーションを設定する。
class Profile < ApplicationRecord
validates :nickname, presence: true
validates :birthday, presence: true
validates :twitter_profile_url, presence: true
validates :facebook_profile_url, presence: true
end
これだけだと、ウィザードの最初のステップでニックネームと誕生日を入力し、その時点の情報をprofiles
に保存する時にその後に入力するtwitter_profile_url
などのバリデーションによって止められてしまう。
validates
メソッドのon
オプションで、そのバリデーションを有効にするタイミングを制御することができるので、それを使うことで一応は解決できそうではある。
class Profile < ApplicationRecord
validates :nickname, presence: true, on: [:step1, :step2]
validates :birthday, presence: true, on: [:step1, :step2]
validates :twitter_profile_url, presence: true, on: :step2
validates :facebook_profile_url, presence: true, on: :step2
end
このようにしておけば、モデルを保存する時に、save(context: :step1)
などとすればステップごとに有効にしたいバリデーションを指定できる。
問題点
on
オプションでバリデーションを追加すると、指定されたコンテキストでしかバリデーションが実行されないので、通常のsaveやupdate時にも有効にさせたい場合との区別が難しい。
このウィザード以外にProfile
を保存や更新する場合に、必須入力であるということがチェックされない。チェックするにはコンテキストを指定する必要がある。
良さそうな実装
profiles
テーブルを分割する。
そもそもウィザード形式で分ける時に、各ステップでの入力項目には意味がある単位にわかれているはず。そうでなければ、何の入力の助けにもならない。
ということで、ニックネームと誕生日、TwitterとFacebookのURLとそれぞれの組でテーブルを分ける。
ActiveRecord::Schema.define(version: 2019_08_11_134450) do
create_table "profiles", force: :cascade do |t|
t.string "nickname"
t.date "birthday"
t.integer "user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id"], name: "index_profiles_on_user_id"
end
create_table "social_profiles", force: :cascade do |t|
t.string "twitter_profile_url"
t.string "facebook_profile_url"
t.integer "user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id"], name: "index_social_profiles_on_user_id"
end
create_table "users", force: :cascade do |t|
t.string "email"
t.string "password_digest"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["email"], name: "index_users_on_email", unique: true
end
end
当然だが、こうするとバリデーションは普通の記述になる。
class Profile < ApplicationRecord
belongs_to :user
validates :nickname, presence: true
validates :birthday, presence: true
end
class SocialProfile < ApplicationRecord
belongs_to :user
validates :twitter_profile_url, presence: true
validates :facebook_profile_url, presence: true
end
おわりに
ウィザード形式を採用する場合に、テーブルを意味のある単位に分割することでバリデーションが複雑にならずに済む。 データの設計がコードに影響することがわかる例だと思う。
分割することで悪い影響はまったく無いのだろうか。
1つあるとすれば、分割されたテーブルの情報を使ってユーザーを検索する場合にJOINするコストが発生することくらいではないだろうか。
例えば、上の例で、social_profiles
テーブルのtwitter_profile_url
とprofiles
テーブルのname
をusers
テーブルのSELECT文のWHERE句で使う場合だ。
2つのテーブルくらいなら大丈夫そうだが、増えていった場合はパフォーマンスの影響も考慮した分割の方法を検討すると良いのかもしれない。
また、既に稼働しているサービスの場合、分割に際してデータ移行が必要となる。
参考
2年前くらいに読んだ「システム設計の原則」の中で似たようなことをやっていたのを思い出したのがきっかけ。 あらためてその部分を読もうと思ったのだけど、失くしてしまったのか、後輩にあげてしまったようなので、また買うかな…

現場で役立つシステム設計の原則 ~変更を楽で安全にするオブジェクト指向の実践技法
- 作者: 増田亨
- 出版社/メーカー: 技術評論社
- 発売日: 2017/07/05
- メディア: 単行本(ソフトカバー)
- この商品を含むブログ (1件) を見る