0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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はこんな感じになります。

スクリーンショット 2025-01-26 12.46.26.png

Contollerでミステリーとコメディが映画に追加されているのでformにも表示されています。
そしてミステリーとコメディもちゃんとi18nで日本語に変換されています。

スクリーンショット 2025-01-26 12.49.52.png

バリデーションで必須にしている項目のrequireも自動で適用されています。

ためしにミステリーの入力でエラーを起こしてみます。

スクリーンショット 2025-01-26 11.46.12.png

ちゃんとエラーになって画面にエラーが伝播しています。

年齢制限を正しい値にして登録します。

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" %>

スクリーンショット 2025-01-26 12.54.13.png

登録が行われ、一覧に表示されました:raised_hands:

最後に

かなり端折ったとはいえ、この土曜日と日曜日でコードを全部書くの疲れたので自分を自分で褒めたいです。
ケースバイケースであるものの、おもしろい実装ではないでしょうか。

今回使ったコードは動く状態にしてこちらのリポジトリに置いてあります。

0
0
0

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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?