Valueオブジェクトパターン(JSON編)

2024年のアドベントカレンダーで[Valueオブジェクトパターン](https://yoshitsugufujii.github.io/blog/2024-11-03-value%E3%82%AA%E3%83%96%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E3%83%91%E3%82%BF%E3%83%BC%E3%83%B3%E3%83%97%E3%83%AA%E3%83%9F%E3%83%86%E3%82%A3%E3%83%96%E5%9E%8B/)というプリミティブ型をオブジェクトでラップするテクニックを紹介しました。  

そこで最後にjson編を書くかもねと宣言していたので、今回は前回の続きでjson編です。

# 要件

本、映画、VOD(Netflix, Huluなど)の作品を紹介するサービスがあったとします。  
本、映画、VODは別々のテーブル、モデルで定義されています。  
それぞれにジャンルに応じたメタ情報を持ちたいです。

| ジャンル | 独自の項目 |
|---------|---------|
| ミステリー        | 怖さ指数、年齢制限、謎の難易度        |
| コメディ        |  笑いのタイプ       | 
| アクション        | アクションの頻度、暴力度、スリル度        |
| ドキュメンタリー        | 社会的メッセージ性、教育性価値        |

ジャンルごとに持つメタ情報は本、映画、VODで共通です。  
そしてジャンルはひとつだけではなく、複数持ちます。  
特に映画ではコメディとアクションの組み合わせはよくありますよね。


# どう実装するか

思いつくのは、 ジャンルごとにテーブルを作って[ポリモーフィック関連付け](https://railsguides.jp/association_basics.html#%E3%83%9D%E3%83%AA%E3%83%A2%E3%83%BC%E3%83%95%E3%82%A3%E3%83%83%E3%82%AF%E9%96%A2%E9%80%A3%E4%BB%98%E3%81%91)で親である、本、映画、VODに紐づける実装でしょうか。  
今回はValueオブジェクトパターンで実装してみたいと思います。

# イメージ

いきなり多言語を使って恐縮なのですが、goの構造体がイメージに近いので引用させてもらいます。  
goでは構造体に構造体を埋め込むことができます。

```golang
type Address struct {
    Street string
    City   string
    Zip    string
}

type Person struct {
    Name    string
    Age     int
    Address Address // Address構造体を埋め込む
}
```

これと同じように本、映画、VODのクラスにジャンルというクラスをあてはめます。  
コード例ではジャンルはミステリーだけですが他もあると思ってください。

```golang
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

説明と表示の割愛のため対象は**映画**。ジャンルは**ミステリ**と**コメディ**のみとします。

## 値オブジェクトの定義

```ruby

# 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を定義

```ruby
# 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を定義

```ruby
# 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

```ruby
# app/models/movie.rb
class Movie < ApplicationRecord
  include GenreAddable

  validates :title, presence: true
end
```

## localeの設定
```yaml

# 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
```ruby
# 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
```ruby
<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
```ruby
<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
```ruby
<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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15318/6f69a44d-8277-3ccd-adaa-be3caac6a923.png)

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

![スクリーンショット 2025-01-26 12.49.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15318/461ab942-3cf7-1e95-6935-ba39229a3726.png)

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

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

![スクリーンショット 2025-01-26 11.46.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15318/e4283905-b02d-2683-82b8-1a581695c07b.png)

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

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

### index.html.erb

```ruby
<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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15318/de90be3b-5395-4b8b-0d0e-f06d0ad5438f.png)


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

# 最後に


ケースバイケースであるものの、おもしろい実装ではないでしょうか。

**Valueオブジェクトパターンは、適切に扱うことでモデルからロジックを分離することができます。Fat Model問題への解のひとつとしても自分の中では強く押したいです。**


今回使ったコードは動く状態にして[こちらのリポジトリ](https://github.com/YoshitsuguFujii/value_pattern_json)に置いてあります。