2024年のアドベントカレンダーでValueオブジェクトパターンというプリミティブ型をオブジェクトでラップするテクニックを紹介しました。
そこで最後にjson編を書くかもねと宣言していたので、今回は前回の続きでjson編です。
要件
本、映画、VOD(Netflix, Huluなど)の作品を紹介するサービスがあったとします。
本、映画、VODは別々のテーブル、モデルで定義されています。
それぞれにジャンルに応じたメタ情報を持ちたいです。
ジャンル | 独自の項目 |
---|---|
ミステリー | 怖さ指数、年齢制限、謎の難易度 |
コメディ | 笑いのタイプ |
アクション | アクションの頻度、暴力度、スリル度 |
ドキュメンタリー | 社会的メッセージ性、教育性価値 |
ジャンルごとに持つメタ情報は本、映画、VODで共通です。
そしてジャンルはひとつだけではなく、複数持ちます。
特に映画ではコメディとアクションの組み合わせはよくありますよね。
どう実装するか
思いつくのは、 ジャンルごとにテーブルを作ってポリモーフィック関連付けで親である、本、映画、VODに紐づける実装でしょうか。
今回はValueオブジェクトパターンで実装してみたいと思います。
イメージ
いきなり多言語を使って恐縮なのですが、goの構造体がイメージに近いので引用させてもらいます。
goでは構造体に構造体を埋め込むことができます。
type Address struct {
Street string
City string
Zip string
}
type Person struct {
Name string
Age int
Address Address // Address構造体を埋め込む
}
これと同じように本、映画、VODのクラスにジャンルというクラスをあてはめます。
コード例ではジャンルはミステリーだけですが他もあると思ってください。
type Genre interface {
Type() string
}
type Mystery struct {
ScareLevel int
AgeRestriction string
PuzzleDifficulty string
}
// ミステリー
func (m Mystery) Type() string {
return "Mystery"
}
// 本の構造体
type Book struct {
Title string
Author string
Genres []Genre
}
// 映画の構造体
type Movie struct {
Title string
Director string
Genres []Genre
}
// VODの構造体
type VOD struct {
Title string
Platform string
Genres []Genre
}
テーブル構成
映画(movies)
カラム名 | データ型 | 説明 |
---|---|---|
id | integer | 主キー |
title | string | 映画のタイトル |
director | string | 映画の監督 |
release_year | integer | 公開年 |
studio | string | 制作スタジオ |
genre | json | ジャンル情報(JSON形式) |
created_at | datetime | レコードの作成日時 |
updated_at | datetime | レコードの更新日時 |
本(books)
カラム名 | データ型 | 説明 |
---|---|---|
id | integer | 主キー |
title | string | 本のタイトル |
author | string | 著者名 |
publish_year | integer | 出版年 |
publisher | string | 出版社名 |
genre | json | ジャンル情報(JSON形式) |
created_at | datetime | レコードの作成日時 |
updated_at | datetime | レコードの更新日時 |
VOD(vods)
カラム名 | データ型 | 説明 |
---|---|---|
id | integer | 主キー |
title | string | VODのタイトル |
creator | string | 制作者または配信元 |
start_year | integer | 配信開始年 |
platform | string | 配信プラットフォーム |
genre | json | ジャンル情報(JSON形式) |
created_at | datetime | レコードの作成日時 |
updated_at | datetime | レコードの更新日時 |
実装するよ
実装するコードのバージョンは次のとおりです
- Ruby version: ruby 3.4.1
- Rails version: 8.0.1
説明と表示の割愛のため対象は映画。ジャンルはミステリとコメディのみとします。
値オブジェクトの定義
# app/models/values/genre/base.rb
module Values
module Genre
class Base
attr_reader :type
def id
uuid
end
def uuid
@uuid ||= SecureRandom.uuid
end
end
end
end
# app/models/values/genre/mystery.rb
module Values
module Genre
class Mystery < Base
include ActiveModel::Model
# 怖さ指数、年齢制限、謎の難易度
attr_accessor :scare_level, :age_restriction, :puzzle_difficulty
# 必須の設定
validates :scare_level, :age_restriction, presence: true
validates :scare_level, numericality: true
validates :age_restriction, inclusion: {
in: [ "12+", "15+", "18+" ],
message: "%{value} は有効な年齢制限ではありません"
}
def initialize(scare_level: nil, age_restriction: nil, puzzle_difficulty: nil, uuid: nil)
@scare_level = scare_level
@age_restriction = age_restriction
@puzzle_difficulty = puzzle_difficulty
@uuid = uuid
end
# 独自のメソッドも作れるよ
def adult_content?
@age_restriction == "18+" || @age_restriction == "15+"
end
def as_json(_options = nil)
{
type: "Mystery",
scare_level:,
age_restriction:,
puzzle_difficulty:,
uuid:
}
end
end
end
end
# app/models/values/genre/comedy.rb
module Values
module Genre
class Comedy < Base
include ActiveModel::Model
# 笑いのタイプ、ユーモアの国別傾向
attr_reader :humor_type, :cultural_bias
# 必須の設定
validates :humor_type, presence: true
def initialize(humor_type: nil, cultural_bias: nil, uuid: nil)
@humor_type = humor_type
@cultural_bias = cultural_bias
@uuid = uuid
end
def as_json(_options = nil)
{
type: "Comedy",
humor_type:,
cultural_bias:,
uuid:
}
end
end
end
end
modelに値オブジェクトを埋め込むためにserializerを定義
# app/models/serializers/genre_serializer.rb
module Serializers
class GenreSerializer
# Rubyオブジェクトを変換
def self.dump(objects)
objects.map { |object|
if object.is_a?(Values::Genre::Base)
object
else
klass = "Values::Genre::#{object['type']}".constantize
klass.new(**object.except("type").symbolize_keys)
end
}.to_json
end
# Rubyオブジェクトに変換
def self.load(json)
objects = json.is_a?(Array) ? json : JSON.parse(json || "[]")
objects.map do |object|
klass = "Values::Genre::#{object['type']}".constantize
klass.new(**object.except("type").symbolize_keys)
end
end
end
end
映画、本、VODにてジャンルを扱うための共通のconcernを定義
# app/models/concerns/genre_addable.rb
module GenreAddable
extend ActiveSupport::Concern
included do
serialize :genre, coder: Serializers::GenreSerializer
validate :validate_genre
end
# ジャンル「ミステリー」を追加
def add_mystery
self.genre ||= []
self.genre << Values::Genre::Mystery.new
end
# ジャンル「コメディ」を追加
def add_comedy
self.genre ||= []
self.genre << Values::Genre::Comedy.new
end
private
def validate_genre
return if genre.map(&:valid?).all?
errors.add(:base, "ジャンルにエラーがあります")
end
end
modelでそのconcernをinclude
# app/models/movie.rb
class Movie < ApplicationRecord
include GenreAddable
validates :title, presence: true
end
localeの設定
# config/locales/ja.yml
ja:
activerecord:
models:
movie: 映画
attributes:
movie:
title: タイトル
director: 監督
studio: スタジオ
release_year: 公開年
genre: ジャンル
created_at: 作成日
updated_at: 更新日
activemodel:
models:
values/genre/mystery: ミステリー
values/genre/comedy: コメディ
attributes:
values/genre/mystery:
scare_level: 怖さ指数
age_restriction: 年齢制限
puzzle_difficulty: 謎の難易度
values/genre/comedy:
humor_type: 笑いのタイプ
cultural_bias: ユーモアの国別傾向
Modelでジャンルを使うための設定とコードはここまでです。
ここからは実際に表示と登録処理をみていきます。
Controller
# app/controllers/movies_controller.rb
class MoviesController < ApplicationController
def index
@movies = Movie.all
end
def new
@movie = Movie.new
# formで選択したジャンルに応じて動的に入力欄を出すのが正しいだけど、
# そこまで実装するとコードが煩雑になってノイズになるのでミステリーとコメディがを選んだものとする
# add_mysteryかadd_comedyを消すと対応する入力欄も消えるのでお試しあれ
@movie.add_mystery
@movie.add_comedy
end
def create
@movie = Movie.new(movie_params)
if @movie.save
redirect_to movies_path, notice: "Movie was successfully created."
else
render :new, status: :unprocessable_entity
end
end
private
def movie_params
movie_params = params.fetch(:movie, {})
movie_params[:genre] = movie_params.fetch(:genre, {}).values
movie_params.permit(
:title,
:director,
:release_year,
:studio,
# この辺りはダラダラかくとどの項目がどの値オブジェクトのものかわからなくて保守性が低いので
# 許可するリストはモデルに持たせるほうがいいです
genre: [
:type,
:scare_level,
:age_restriction,
:puzzle_difficulty,
:humor_type,
:cultural_bias
]
)
end
end
View
app/views/application.html.erbでbootstrapのcssを読み込んでいます。
またbootstrap-formを使っています。
app/views/movies/new.html.erb
<h1 class="mt-5">映画入稿</h1>
<% if @movie.errors.any? %>
<div class="row text-danger">
<%= "#{Movie.model_name.human}の入力内容に誤りがあります" %>
</div>
<% end %>
<%= bootstrap_form_with(model: @movie, local: true, layout: :horizontal) do |f| %>
<div class="mb-3">
<%= f.text_field :title, class: "form-control" %>
</div>
<div class="mb-3">
<%= f.text_field :director, class: "form-control" %>
</div>
<div class="mb-3">
<%= f.number_field :release_year, class: "form-control" %>
</div>
<div class="mb-3">
<%= f.text_field :studio, class: "form-control" %>
</div>
<% f.object.genre.each do |g| %>
<%= f.fields_for "genre[]", g do |fg| %>
<% # mysteryの場合は"genre/mystery"となる %>
<%= render "#{fg.object.class.name.underscore.split('/')[1..].join('/')}", fg: %>
<% end %>
<% end %>
<%= f.submit "入稿", class: "btn btn-primary" %>
<% end %>
app/views/genre/_mystery.html.erb
<h2>
<%= fg.object.class.model_name.human %>
</h2>
<div>
<%= fg.hidden_field :type, value: "Mystery" %>
</div>
<div>
<%= fg.number_field :scare_level %>
</div>
<div>
<%= fg.text_field :age_restriction %>
</div>
<div>
<%= fg.text_field :puzzle_difficulty %>
</div>
app/views/genre/_comedy.html.erb
<h2>
<%= fg.object.class.model_name.human %>
</h2>
<div>
<%= fg.hidden_field :type, value: "Comedy" %>
</div>
<div>
<%= fg.text_field :humor_type %>
</div>
<div>
<%= fg.text_field :cultural_bias %>
</div>
そうするとformはこんな感じになります。
Contollerでミステリーとコメディが映画に追加されているのでformにも表示されています。
そしてミステリーとコメディもちゃんとi18nで日本語に変換されています。
バリデーションで必須にしている項目のrequireも自動で適用されています。
ためしにミステリーの入力でエラーを起こしてみます。
ちゃんとエラーになって画面にエラーが伝播しています。
年齢制限を正しい値にして登録します。
index.html.erb
<h1 class="mt-5">映画一覧</h1>
<table class="table">
<thead>
<tr>
<th><%= Movie.human_attribute_name(:title) %></th>
<th><%= Movie.human_attribute_name(:director) %></th>
<th><%= Movie.human_attribute_name(:release_year) %></th>
<th><%= Movie.human_attribute_name(:studio) %></th>
<th><%= Movie.human_attribute_name(:genre) %></th>
</tr>
</thead>
<tbody>
<% @movies.each do |movie| %>
<tr>
<td><%= movie.title %></td>
<td><%= movie.director %></td>
<td><%= movie.release_year %></td>
<td><%= movie.studio %></td>
<td>
<%= movie.genre.map{ it.class.model_name.human }.join('/') %>
</td>
</tr>
<% end %>
</tbody>
</table>
<%= link_to "映画入稿", new_movie_path, class: "button" %>
登録が行われ、一覧に表示されました
最後に
かなり端折ったとはいえ、この土曜日と日曜日でコードを全部書くの疲れたので自分を自分で褒めたいです。
ケースバイケースであるものの、おもしろい実装ではないでしょうか。
今回使ったコードは動く状態にしてこちらのリポジトリに置いてあります。