プログラミング学習 備忘録

Railsを学習していく上での技術メモ。学んだことや解決したエラーなどを記録していきます。

【決断くん】※こだわりポイント※ 重みづけを用いた抽選機能を実装

今回実装したいこと

1、「最終確認」画面と「決断結果」画面の間に、「武士の情け」画面を挟む

2、「武士の情け」画面で選ばれた選択肢は、決断くんによって選ばれやすくなる(70%の確率で選ばれるようにする)

gyazo.com



実装方法

今回の作戦

1、new→confirm画面への遷移と同様に、hidden_fieldを使って、武士の情け(compassion)ページにタイトルと選択肢のパラメータを渡す。

2、compassionページには、お馴染みのhidden_fieldとf.radio_buttonでラジオボタンを設け、お題(:title)と5つの選択肢(:option_1~5)、そしてラジオボタンで選択された選択肢(:primary_option)を全て取得できるように。

3、createアクションとは別に、重みづけ専用のcompassion_createアクションを用意。 [option_1, option_2, …, primary_option]の形の配列にして、 gem ‘weighted_randomizer’を使って重みづけを行い抽選する。

1、「武士の情け(compassion)」ページへのルーティングとコントローラーを実装

# route.rb
  resources :choices, only: [:new, :create, :edit, :update, :index, :show] do
    collection do
      post :confirm, :compassion
      get :alert
    end

confirmと同様に、collectionを用いてネストさせる。
new→confirmと同様に、confirmからcompassionにパラメータを渡すのでリクエストはpostに。

# choices_controller
class ChoicesController < ApplicationController
  before_action :set_choice_instance, only: [:confirm, :create, :edit, :compassion, :compassion_create]
.
.
  def compassion; end
.
.
  private

  def set_choice_instance
    return if current_user.nil?

    @choice = current_user.choices.build(choice_params)
  end

ここは関してはnew→confirmへの遷移と一緒。

# choices/confirm.html.slim

  = form_with model: @choice, local: true do |f| 
    = f.hidden_field :title
    = f.hidden_field :option_1
    = f.hidden_field :option_2
    = f.hidden_field :option_3
    = f.hidden_field :option_4
    = f.hidden_field :option_5
    <br><br>
    = f.submit 'いざ!', class: 'ui secondary button'
    = f.submit '選択肢を考え直す', name: 'back', class: 'ui secondary button'
  
  = link_to root_path, class: 'ui secondary button'
    | #{t '.back'}

  = form_with model: @choice, url: compassion_choices_path, local: true do |f| 
    = f.hidden_field :title
    = f.hidden_field :option_1
    = f.hidden_field :option_2
    = f.hidden_field :option_3
    = f.hidden_field :option_4
    = f.hidden_field :option_5
    = f.submit '情けをかける', class: 'ui secondary button'

1つのフォームにまとめたかったのだが、form_withでsubmitボタンによって遷移先を複数に分割させることが出来ない?みたいなので2つのフォームに分割。 だいぶ冗長なので、partial化した方がいいかもしれない。

次からが本番

2、compassionページのビューを実装

# choices/compassion.html.slim

# 通常通りにpathを使ってurlを指定すると、createアクションが発動してしまうのでcontrollerとactionを明示的に渡す。
 = form_with model: @choice, url: {controller: 'choices', action: 'compassion_create'}, local: true do |f|

# 下の6行は、:titleおよび5つの選択肢をパラメータとして取得するためのものなのでhidden属性にする。
    = f.hidden_field :title
    = f.hidden_field :option_1
    = f.hidden_field :option_2
    = f.hidden_field :option_3
    = f.hidden_field :option_4
    = f.hidden_field :option_5
    <br><br>
    <div class='grouped fields'>

 # f.radio_buttonで各選択肢ごとにラジオボタンを設け、5つの選択肢から1つが選べるように。
    .field
      .ui.radio.checkbox
        = f.radio_button :primary_option, @choice.option_1, checked: true
        = f.label @choice.option_1
    <br>
    .field
      .ui.radio.checkbox
        = f.radio_button :primary_option, @choice.option_2
        = f.label @choice.option_2
    <br>
    - if @choice.option_3.present?
      .field
        .ui.radio.checkbox
          = f.radio_button :primary_option, @choice.option_3
          = f.label @choice.option_3
    <br>
    - if @choice.option_4.present?
      .field
        .ui.radio.checkbox
          = f.radio_button :primary_option, @choice.option_4
          = f.label @choice.option_4
    <br>
    - if @choice.option_5.present?
      .field
        .ui.radio.checkbox
          = f.radio_button :primary_option, @choice.option_5
          = f.label @choice.option_5
    <br>
    = f.submit 'いざ!', class: 'ui secondary button'

どうやって実装するか非常に悩んだが、5つの選択肢(:option_1~5)とラジオボタンで選択した選択肢(:primary_option)を配列にして、gemを使って重みづけを行うのが最適だと考えた。

Choicesテーブルとresultテーブルを分けて実装しておけばもっと簡単だったと後悔。テーブル設計ほどほどに実装始めてしまったのが仇となった。

これにより、submitボタンを押した際に以下のパラメータがchoices_controllerのcompassion_createアクションに渡されるようになる。

# 情けをかける画面で「ラーメン」を選択している
 Parameters: {"authenticity_token"=>"ANYnfC7SwwI97vg4wd6EcH/WyRbVjCou0+WkZF6UETpoI+TfVts+7d24eM+86SCb2p7JjdyK0kk5n+4+/nIRfQ==", "choice"=>{"title"=>"今日の晩ご飯", "option_1"=>"カレー", "option_2"=>"パスタ", "option_3"=>"ラーメン", "option_4"=>"", "option_5"=>"お寿司", "primary_option"=>"ラーメン"}, "commit"=>"いざ!"}

3、compassion_createアクションを定義する

class ChoicesController < ApplicationController
  before_action :set_choice_instance, only: [:confirm, :create, :edit, :compassion, :compassion_create]
.
.
  def compassion_create
    decide_with_compassion
    if params[:back]
      render :new
    elsif @choice.save
      flash[:success] = t 'choices.flash.new decide with compassion'
      redirect_to result_choice_path(@choice.id)
    end
  end

    private

  def set_choice_instance
    return if current_user.nil?

    @choice = current_user.choices.build(choice_params)
  end

      def decide_with_compassion

    queues = if @choice.option_5.present?
               { @choice.option_1.to_s => 7, @choice.option_2.to_s => 7, @choice.option_3.to_s => 7, @choice.option_4.to_s => 7,
                 @choice.option_5.to_s => 7, (params[:choice][:primary_option]).to_s => 65 }
             elsif @choice.option_4.present?
               { @choice.option_1.to_s => 10, @choice.option_2.to_s => 10, @choice.option_3.to_s => 10, @choice.option_4.to_s => 10,
                 (params[:choice][:primary_option]).to_s => 60 }
             elsif @choice.option_3.present?
               { @choice.option_1.to_s => 15, @choice.option_2.to_s => 15, @choice.option_3.to_s => 15,
                 (params[:choice][:primary_option]).to_s => 55 }
             else
               { @choice.option_1.to_s => 30, @choice.option_2.to_s => 30, (params[:choice][:primary_option]).to_s => 40 }
             end
    randomizer = WeightedRandomizer.new(queues)
    @choice.result = randomizer.sample
  end

見辛すぎて申し訳ない。
decide_with_compassion以外は通常のcreateアクション(ランダム抽選機能)とほぼ同じなので割愛。 今回は、if @choice.option_4.present?(選択肢が4つある場合)を例に簡単に解説する。

  def decide_with_compassion

#ハッシュの中で各選択肢を重みづけし、queuesに格納する
    queues = { @choice.option_1.to_s => 10, @choice.option_2.to_s => 10, @choice.option_3.to_s => 10, @choice.option_4.to_s => 10,
                 (params[:choice][:primary_option]).to_s => 60 }

# gem:WeightedRandomizerが自動的に、queuesを元にして重み付けされた配列を作ってくれる。
    randomizer = WeightedRandomizer.new(queues)

# 後はそれをsampleメソッドによって抽選し、@choice.resultに格納。
    @choice.result = randomizer.sample
  end

params[:choice][:primary_option]).to_s => 60が、70ではないのが腑に落ちないかもしれないが、@choice.option_1~4のうちのどれか1つがparams[:choice][:primary_option])と重複しているため、合計数を70にすることが重要。 コード自体は非常に冗長になってしまうが、これを応用すれば選択肢が何十個あっても好きな確率に設定可能。

今回お世話になったgem: Weighted Randomizerについては公式のGitHubをご参照ください。
GitHub - ryanlecompte/weighted_randomizer: Provides a common utility for weighted randomization


まとめ

実はこの機能、このサービスを作ることを決意した時からもっとも実装したかった機能です。
日常生活で、「こっちの商品の方がいいんだけど、こっちも少し捨てがたいな〜」って時が結構あるからそこを補いたいなと思っていました。
似たようなサービスはいくつかあるものの、この機能を実装しているサービスは1つもなかったですし、ここが決断くんのこだわりポイントです。

どうやってパラメータ渡そうかとか、渡した後はどうやって重み付けしようかとか、正直かなり悩みました。
テーブル設計の甘さと技術不足が祟り、コードは結構ぐちゃぐちゃになってしまいましたが自分の力だけで実装できて良かったし、自信に繋がりました。

もしもっといい実装方法あればコメントなどで教えていただけると幸いです。