Railsのform_forとform_withの違いを調べてみた

これは、「フィヨルドブートキャンプ Part 2 Advent Calendar 2023」の19日目の記事です。
昨日の記事は、machidaさんの「[文字起こし] TokyuRuby会議 14 で Tokyu.rb のロゴを作った話の LT をしました🎨」とpenoさんの「Ruby のメソッドのソースコード(C言語)を初めて読んだ」です。

今年のフィヨルドブートキャンプのアドベントカレンダーは、以下となります。
フィヨルドブートキャンプ Part 1 Advent Calendar 2023 - Adventar

フィヨルドブートキャンプ Part 2 Advent Calendar 2023 - Adventar

はじめに

Railsで5.1以降はform_forform_tagの代わりにform_withを使うことがRailsガイドに言及されています。 しかし、form_forform_withが似たメソッドで違いについてあまり理解できてないです。
類似記事は沢山ありますが、改めて自分で内部実装やドキュメント等を調べて違いを整理しました。

調査した環境のバージョンは以下となります。

form_withとform_forの違いまとめ

Railsガイドには、form_withの機能について以下のように記載されてあります。

Rails 5.1でform_withが導入されるまでは、form_withの機能はform_tagとform_forに分かれていました。

つまり、form_withは、汎用的にform_tagform_forの両機能を利用することが出来るということです。

form_withform_forの違いは結論から言うと、form_withには以下の2点の特徴があり、form_forは以下の2点とは逆の特徴を持ちます。

  • フォームタグにHTMLのclassidを付与しない
  • フィールドタグのmethodオプションにモデルの属性以外も指定可能

form_forはform_withを呼び出す

先程特徴の違いを2点書きましたが、form_forの実装内容を見るとform_withとの違いがより理解しやすいです。
というのも、form_forのコードを見ると分かりますが処理内容は以下のようになっています。(actionview7.0.7の場合)

  1. form_forのオプションをform_withのオプションに加工する
  2. form_withを呼び出す

そのためform_forform_withの違いとなる要素は、form_withで利用するオプションの違いを比較すれば良いわけです。

実際に例を見てオプションの差を確認します。Messageモデルを指定した場合にform_wiht内で利用されるオプションを比較してみます。 比較箇所はform_withで呼び出されるprivateメソッドのhtml_options_for_form_withで、optionshtml_optionsの値を見ます。

def html_options_for_form_with(url_for_options = nil, model = nil, html: {}, local: !form_with_generates_remote_forms,
  skip_enforcing_utf8: nil, **options)
  html_options = options.slice(:id, :class, :multipart, :method, :data, :authenticity_token).merge!(html)
  html_options[:remote] = html.delete(:remote) || !local
  html_options[:method] ||= :patch if model.respond_to?(:persisted?) && model.persisted?
  if skip_enforcing_utf8.nil?
    if options.key?(:enforce_utf8)
      html_options[:enforce_utf8] = options[:enforce_utf8]
    end
  else
    html_options[:enforce_utf8] = !skip_enforcing_utf8
  end
  # ここでの`options`と`html_options`を比較する
  html_options_for_form(url_for_options.nil? ? {} : url_for_options, html_options)
end

実行するform_withform_forのコードは下記になります。
比較のためにform_forform_withで指定するオプションは最低限です。

form_forのコード

form_for @message, url: messages_path do |f|
end

form_withのコード

form_with model: @message, url: messages_path do |f|
end

上記2つのコードを実行した場合に、form_with内で利用されるオプションの違いは以下となります。

options、html_optionsのキー form_forで呼び出す場合 form_withで呼び出す場合
options[:allow_method_names_outside_object] false true
options[:skip_default_ids] false false
options[:multipart] nil nil
html_options[:id] "edit_message_1" キーなし
html_options[:class] "edit_message" キーなし
html_options[remote] false false

違いとなるオプションは、:id:class:allow_method_names_outside_objectです。
:id:classはフォームタグにidを付与するものです。
:allow_method_names_outside_objectはRailsAPIDocsに記載がないですが、フィールドタグにモデル属性以外の許可を設定するものです。

form_forがモデルの属性以外を設定できない理由

form_forがフィールドタグにモデルの属性以外を指定できない理由を見てみます。
フィールドタグの制約に影響するデータはform_withオプションの:allow_method_names_outside_objectです。form_forを呼び出したときの:allow_method_names_outside_objectの値は、先程見たオプションの違いの表からfalseであることが分かりました。
これを利用するコードはフォームタグのヘルパーメソッドのActionView::Helpers::Tags::Base#valueになります。 フィールドタグのvalue属性を設定する値を取得するメソッドになります。

def value
  if @allow_method_names_outside_object
    object.public_send @method_name if object && object.respond_to?(@method_name)
  else
    object.public_send @method_name if object
  end
end

valueメソッドは、 :allow_method_names_outside_objectがtrueの場合は、public_sendを呼び出さないためnilを返します。

一方で:allow_method_names_outside_objectがfalseの場合は、public_sendを呼び出すためメソッドがない場合はエラーが発生します。
このような仕組みによってform_forがモデル属性以外を指定できないように制御しています。

最後に

今回はRails7の内部実装を見て違いについて理解を深めました。Rails6の場合だと実装内容が異なるので、今回は調査対象外にしました。Rails6からRails7で実装内容を変えた経緯を調べるのも面白そうです。

Railsのvalidatesメソッドでallow_blankを利用するときに気をつけること

Railsのバリデーションには共通オプションの allow_blank があります。 allow_blank は属性値が blank? の場合にバリデーションがパスされるものです。 validates メソッドにこのオプションを利用した際に、意図しない結果が出ました。原因はオプションの扱いが不適切だったことです。

オプションの設定によってバリデーションが意図しない挙動をし、データ作成や画面操作にも影響があり原因調査が厄介でした。 そのため、注意点と原因をブログに残します。

原因を調査したRailsのバージョンは6.1.4.7です。

注意点

validatesでallow_blank を使う際に気をつけるべきことは、allow_blankをValidator内のオプションとして設定せず、Validator外に設定することです。

つまり、以下の例のようにValidator内に allow_blank 書くのではなく、

validates :description, length: { maximum: 32, allow_blank: true }

このようにValidator外に書きます。

validates :description, length: { maximum: 32 }, allow_blank: true

Validatorが2つ以上含む場合は特に注意が必要です。いずれかのValidator内に allow_blank の設定が無い場合、バリデーションのスキップが出来ず意図しない結果となる可能性が高いです。

バリデーションのスキップが出来るケースと出来ないケースを見ていきます。 属性 descriptionの値をブランクにし、バリデーションの設定ごと(ケース1〜ケース5)の valid? の結果を確認します。

ケース1:Validatorが1つの場合かつ lengthValidator外にallow_blankを設定

validates :description,
              length: { maximum: 32 }, allow_blank: true

ケース2:Validatorが1つの場合かつ lengthValidator内にallow_blankを設定

validates :description,
              length: { maximum: 32, allow_blank: true }

ケース3:Validatorが2つの場合かつ lengthValidator内にallow_blankを設定

validates :description,
              length: { maximum: 32, allow_blank: true },
              format: {
                with: /\\A(?!.*\\.\\.)[a-z0-9_.]\\z/,
              }

ケース4:Validatorが2つの場合かつ formatValidator内にallow_blankを設定

validates :description,
              length: { maximum: 32 },
              format: {
                allow_blank: true,
                with: /\\A(?!.*\\.\\.)[a-z0-9_.]\\z/,
              }

ケース5:Validatorが2つの場合かつ Validator外にallow_blankを設定

validates :description,
              allow_blank: true,
              length: { maximum: 32, allow_blank: true },
              format: {
                with: /\\A(?!.*\\.\\.)[a-z0-9_.]\\z/,
              }

これらのケースで自分が期待したことは、allow_blankがバリデーションをスキップするので、valid?の結果が全てのケースでtrueになることでした。valid?を実行した結果は以下となります。

valid?
ケース1 true
ケース2 true
ケース3 false
ケース4 true
ケース5 true

ケース3のみfalseとなりました。しかし、LengthValidatorにminimumを追加するとケース4でもfalseになります。このように、allow_blankを設定する箇所によってバリデーションがスキップ出来ないケースがあります。

原因

allow_blankの設定箇所によってvalid?の結果が異なる原因を探るために、 validates で設定したオプションの扱い方と allow_blank のチェック方法についてRailsのコードを見てみます。

validatesで設定したオプションの扱い方

まずは、 validates で設定したオプションをどのように扱っているのか見てみます。処理の概要は以下となります。

  • validatesの引数は複数のvalidatorと複数のオプション(Validator外)が混合しているため、最初にValidatorとオプションに分類。
    • 例えば、 length: { minimum: 8 }, allow_blankの場合、Validatorはlength、オプションはallow_blank
  • Validator外に設定されたオプションを各Validator内のオプションとして追加。
    • length : { minimum: 8 }, allow_blank: trueの場合、LengthValidatorのオプションは、{ minimum: 8, allow_blank: true }となる。
  • Validator内に設定されたオプションはそのまま。他のValidatorに追加されない。

実際のコードは以下になります。一部コードの省略とコメントを追加しています。

def validates(*attributes)
  defaults = attributes.extract_options!.dup # Validator外で設定したオプション
  validations = defaults.slice!(*_validates_default_keys) # ValidatorとValidator内で設定したオプション
  # 省略
  validations.each do |key, options|
    key = "#{key.to_s.camelize}Validator"
    # 省略
    # Validator内とValidator外のオプションをマージ
    # validates_withでValidatorにレコードを渡す
    validates_with(validator, defaults.merge(_parse_validates_options(options))) 
  end
end

各Validatorごとにバリデーションを実行

次に valid? を実行したときの挙動を見てみます。こちらの処理の概要は以下になります。

  • レコードのバリデーション(EachValidator#validate)を実行。Validator共通の処理はここで行い、Validator固有の処理は validate_each で実行。
  • validate でValidatorのオプションに共通オプション allow_blank が設定され、かつ値がブランクであった場合、 validate_each の実行はスキップされる。

実際のコードは以下になります。

def validate(record)
  attributes.each do |attribute|
    value = record.read_attribute_for_validation(attribute)
    # Validatorの共通処理。allow_blankがtrueで設定される&値がブランクであればバリデーションをスキップ。
    next if (value.nil? && options[:allow_nil]) || (value.blank? && options[:allow_blank])
    value = prepare_value_for_validation(value, record, attribute)
    validate_each(record, attribute, value)
  end
end

まとめ

validates で設定したオプションの扱い方と allow_blank のチェック方法について調べたまとめは以下となります。

  • Validator内に allow_blank の設定があればブランクのチェックを行い、チェックが通ればバリデーションの実行はスキップされる。
  • validates全ての Validatorに allow_blank を設定するには、Validator外に allow_blank を設定するor 全てのValidator内に allow_blank を設定する。

このことからケース3がfalseになった理由は下記となります。

FormatValidatorのオプションに allow_blank が設定されてないため、バリデーションのスキップされず、FormatValidatorのバリデーションが実行される。 バリデーションのwith オプションによるチェックは、値のブランクに無効であるため false の結果となる。

最後に

allow_blankは複数のValidatorに影響するオプションです。その点を踏まえれば、 Validator内に書くことはしないのでは?と思いました。ただ何となくオプションを使わないように気をつけたいです。

定数をネストで参照しなかったので無限ループが発生した

以前、自作Gemでこんなコードを書いてたら、SystemStackErrorが発生しました。

module Exfuz
  class Queue
    def initialize
      @data = Queue.new
    end
  end
end

queue = Exfuz::Queue.new

4行目に代入するオブジェクトのクラスQueueThread::Queueのつもりで書いてました。QueueThread::Queueエイリアスなので。

実行すると、エラーが発生しました。エラーメッセージは以下が出ます。

initialize': stack level too deep (SystemStackError)
        from (irb):42:in `new'
        from (irb):42:in `initialize'
        from (irb):42:in `new'
        from (irb):42:in `initialize'
        from (irb):42:in `new'
        from (irb):42:in `initialize'
        from (irb):42:in `new'
        from (irb):42:in `initialize'
         ... 11381 levels...

原因は、ネストしたクラス内部で::演算子を使わず直接参照を用いたためです。Exfuz::Queue内部でのQueueExfuz::Queueとなります。そうなると初期化が繰り返され、SystemStackErrorが発生しました。

解決策としては、Thread::Queueで参照すること。もしくは、エイリアスQueueとして参照するなら左辺無しの::Queueで参照します。左辺無しの::はObjectクラスで定義された定数です。

参考文献

docs.ruby-lang.org

Excelを曖昧検索できるgem「exfuz」をリリースしました

複数のExcelファイルを、fzfやpeco等のfuzzy finderツールを用いて曖昧検索ができるgem「exfuz」をリリースしました!

目次

gemの概要

exfuzは、複数のExcelファイル内のテキスト(現バージョンではセルのテキストのみ)を対象に曖昧検索が出来るTUIツールです。曖昧検索にはfuzzy finderツール*1を利用します。

さらにwsl環境でgemを利用する場合、曖昧検索で選択した箇所へのジャンプが可能です。つまり、選択したセルをアクティブ状態にすることが可能です。

https://user-images.githubusercontent.com/85052152/213947172-8917d9a8-1f88-42a2-9fc1-53c8de25e309.gif

開発した経緯

開発した経緯は、前職で経験したExcelの辛い問題を解決したいためです。

私の前職は、SES企業(SIer業界)のプログラマです。
いくつかのプロジェクトを携わりましたが、要件定義や設計等を管理するドキュメントツールはExcelでした。Excelは便利機能が豊富ですが、ドキュメントを管理する手段としては辛い点が多々ありました。
特に辛いと感じた箇所は以下です。

  • 検索機能が使いづらい
    • 正規表現検索が出来ない
    • 検索文字列の入力から検索までの手順が手間
  • 複数のファイル(Book)を横断して検索ができない
  • ターミナル上で検索できない

ドキュメント数が少ない場合は、上記の不便さに目を瞑ることも出来ました。
しかし、プロジェクト規模が大きい場合、そうは行かなかったです。ドキュメント数が膨大になり、ナレッジの検索に多大な時間を要するため、非効率な時間が増えました。

この問題は私だけでなく、当時のチームメンバーも同様の悩みを抱えてました。(ターミナル上で検索したいというメンバーがいるかは定かではないですが。。。)

このような背景から、Excelを使ったドキュメント管理の苦労を削減できればと思い、TUIツールを作ることにしました。

使い方

ここでは、exfuzの最低限の使い方について解説したいと思います。詳細はREADMEをご覧ください。

まず、起動コマンドは以下となります。

exfuz start

起動後の画面は、初期画面と曖昧検索画面の2つで構成されます。
初期画面では、情報の表示とキー入力によるイベントの受付けを担います。
曖昧検索画面では、Excelファイル内のテキストを曖昧検索します。曖昧検索の対象ファイルは、カレントディレクトの階層内のxlsxファイルとなります。

初期画面

起動をすると、以下の初期画面が表示されます。

「exfuz start」起動時の初期画面

上図の①は、クエリの入力エリアになります。入力した文字列は、曖昧検索の対象(セルのテキスト)をフィルタリングすることが可能です。

②は、xlsxファイルの解析ステータスになります。内訳としては以下となります。

  • 左:現時点で曖昧検索可能なファイル数
  • 右:曖昧検索の対象となる全ファイル数

①について詳細に説明します。例として、2つのxlsxファイルを曖昧検索の対象にexfuzを起動してます。

クエリがブランクの場合は、2つのxlsxファイルの全セルが曖昧検索の対象となります。下図は、クエリがブランクのときの曖昧検索画面になります。

クエリがブランクの場合の曖昧検索画面

次に、クエリに文字列がある場合です。
例えば、クエリエリアに正規表現test.*data を入力します。この場合の曖昧検索画面では、下図のようにクエリにマッチしたセルのみ曖昧検索の対象となります。

クエリに文字列を入力した場合の曖昧検索画面

fuzzy finderツールで選択した後は、初期画面に戻ります。下図の③のように選択行の一覧が表示されます。

曖昧検索で選択した後の初期画面

曖昧検索画面

曖昧検索画面は初期画面から遷移でき、下図のような画面が表示されます。

fzfを使用した場合の曖昧検索画面

曖昧検索画面の構成はfuzzy finderツールに依存します。上図の曖昧検索画面は、fuzzy finderツールであるfzfを使用した場合です。

標準入力の各1行ごとの内容は、以下のようなフォーマットで構成された文字列となります。フォーマットは、各項目ごとに区切り文字を連結した文字列です。

行番号:ファイル名:シート名:セル番地:セルのテキスト

行番号は、標準入力に渡したデータの順序となります。また、各項目ごと区切り文字は:です。

使用技術とシステム構成

使用した技術は以下となります。

次に、exfuzのコア機能のシステム構成について概要を説明します。
下図はシステム構成図となります。

exfuzのコア機能のシステム構成

全体的な処理の流れは、左から右への一方通行となります。

はじめに上図①のパース処理です。 Excelで利用されるxlsxファイルはxmlファイルの圧縮ファイルで構成されています。そのため、普段私達が見慣れた形式(ブック、シート、セル)に変換するためにパース処理を行います。
次に、パースされたデータをキューに格納していきます。データは、「ブック名、シート名、セル番地、セル内の値」から構成されてます。

そして、曖昧検索を起動すると上図②、③の処理を実行します。
キュー内のデータからレコード格納オブジェクトにデータを移します。 レコード格納オブジェクトでは、フィルタリングやグルーピングが出来る振る舞いを用意しました。フィルタリング機能は、クエリによる曖昧検索対象の絞り込みに用います。
最後に、fuzzy finderツールをサブプロセスで起動し、標準入力にデータを流し込みます。

処理の流れは逐次ですが、実際には並行処理が行われています。 上図の2色の矢印(緑色とオレンジ色)でスレッドを分け、スレッド構成は以下となります。

  • 緑色矢印の処理:メインスレッド(プログラム実行時に作成されたスレッド)
  • オレンジ色矢印の処理:ワーカースレッド(メインスレッドとは別のスレッド)

このように処理を別のスレッドに分けることで、パース処理が未完了の状態でも曖昧検索を可能にしました。

技術選定

使用技術とシステム構成 で述べましたが、exfuzはいくつかのgemを利用しています。ここでは、重要な処理を担うgemのXsv を選定した理由について述べます。

exfuzはExcelのファイル、つまりxlsxファイルをパースし、ブック、シート、セル等のExcelの構成要素に変換します。 Xsv はこのパース処理を担います。
Excelをパースするgemは他にもいくつかありますが、その中から Xsv を選定した理由は、パース処理が高速だからです。

xlsxファイルは、xmlファイル群をzip形式で圧縮したものです。テキストファイル同様に曖昧検索を可能にするためには、パース処理が必要になります。Excelの曖昧検索の速度は当然、テキストファイルの曖昧検索と比較すると遅いです。そのため、いかにExcelとテキストファイル間で曖昧検索の処理速度の差を縮められるかが、ユーザー体験を向上させる上で重要だと考えました。

パース処理の速度の調査と検証は、以下のブログを見た上で、軽めのパフォーマンス検証を行いました。その結果、最も高速だったXsvを採用するに至りました。

schembri.me

パフォーマンス以外でも実装コストの懸念は若干ありました。Xsv以外のgemは、概ねExcelの高機能な操作が可能なため、実装コストの削減でメリットがありました。しかし、リリース予定のexfuzは、機能をシンプルにしたかったので、パース後のExcel操作は最低限の自前実装でも何とかなると判断しました。

苦労した点

苦労したことは、設計と実装です。

exfuzの機能はシンプルですが、設計と実装の難易度が、経験上最も高かったです。スクラッチ開発が必要な機能が多く、そのような機能の設計・実装に着手した当初は、方針の目処が全く立たない状況でした。

そんな状況下でも少しずつですが、開発に粘り強く取り組みました。小さく問題を切り分けたり、知識を増やしていくことで自力でリリースすることが出来ました。設計や実装方法の知見は、類似した機能を持つOSSAPIやコード、技術書を参考にして増やしました。

具体的には、以下のOSS、技術書が大変参考になりました。

直近で対応したいこと

リリース速度を優先させたため、改善すべき点や追加したい機能が沢山あります。課題がある中で、直近で対応したいことは以下があります。

  • コードのリファクタリング
    • テストコードも含めて汚いです。モジュール化はある程度しましたが、特に命名が分かりづらいので早期に修正したいです。
  • Windows環境対応
    • Rubyの実行環境がWindowsの場合、exfuzの端末制御にバグがあるため使用できない状況です。Windows環境で利用するユーザー層が多いと思うのでこちらも早期に対応したいです。
  • exfuzのシングルバイナリ化
    • ユーザーの中にはRubyが入ってない環境もあると思われるので、手軽に導入出来るようにしたいです。

最後に

今回の開発では、OSSのコードリーディングをしたことが非常に良い経験となりました。OSSの設計・実装がハイレベルなので、コードを読み解く経験が私の血肉となりました。将来的には、今回参考にしたOSSのような技術力を身につけたいので、自己研鑽に努めてきたいと思います。
そして、OSSに対しての感謝の気持ちが一段と増しました。スクラッチ開発で苦労したこともあり、普段何気なく使っていたOSSへの有難みが身に沁みました。

TwitterExcelに関するツイートを眺めると、「Excelの検索が辛い」と感じるユーザーは多そうでした。開発したgemによって、ユーザーの苦労を少しでも減らせたら嬉しいです。
しかし、リリースを優先したこともあって、exfuzは、機能数も少なく使い勝手が悪い部分も多いです。
より良くするために今後も開発を継続していきたいと思います。

*1:デモではfzfを利用してます。

フィヨルドブートキャンプ内で「1分間スピーチ会」を開いてみた

これは、「フィヨルドブートキャンプ Part 2 Advent Calendar 2022」の23日目の記事です。
昨日の記事は、@uchihiro04さんの「VSCodeのパッケージ不適合によるエラー解決までの道のり(Byebug編)」です。発生したエラーに対して、諦めずに検証を繰り替えした姿勢を見習いたいと思いました!

今年のフィヨルドブートキャンプのアドベントカレンダーは、以下となります。
フィヨルドブートキャンプ Part 1 Advent Calendar 2022 - Adventar

フィヨルドブートキャンプ Part 2 Advent Calendar 2022 - Adventar

はじめに

フィヨルドブートキャンプ(以下FBC)で学習中の@wata00913です。FBCはよくコミュニティと言われ、受講生がイベントを盛んに開催します。
例えば、輪読会、ペアプロ、勝手にペアプロ会、目覚まし会、もくもく会、グループコーチングなどがあります。

受講生が自発的にイベントを開催する様子を見て、自分も何か開いてみたいと思い、6月から「1分間スピーチ会」を開催しました。

この記事では、開いてみた経緯、やってみた感想などをまとめました。1分間スピーチ会がどのようなものか、雰囲気が伝われば幸いです。

目次

どんなことをするのか?

内容は、一般的な1分間スピーチをベースにしました。一般的な1分間スピーチとは、発表者が話したいテーマを1分以内で発表します。
FBC内で開催した1分間スピーチ会の内容は、以下になります。

  • 構成
    • (発表+質疑応答)✕ 人数
      • 質疑応答はコメントも可。
  • 持ち時間
    • 発表時間は2分(1分はバッファ)
    • 質疑応答時間は6分
  • 発表テーマ
    • テーマは自由
    • 思いつかない場合は、「事前テーマ」を発表する
    • 事前テーマは、主に「面接で聞かれそうな質問」
        • エンジニアを目指す理由
        • 仕事で大切にしていること
        • 成長するためにやっていること
  • 開催時刻
    • 月曜の8:00~8:30と13:00~13:30
      • 参加人数は、wata含めて約3名

イベントを開いた背景

イベントを開いた背景は、主に次の3つとなります。

  • 微力でもFBCコミュニティに貢献したい
  • ソフトスキル関連のイベントも増やしたい
  • 受講生の方に開催を宣言して、背中を押してもらった

FBCのコミュニティでは、受講生が活動的です。一方で私は、コミュニティに貢献が出来てないことを痛感してました。
受講生が自然とコミュニティに貢献している姿を見る度に、自分も何か微力ながら貢献したい気持ちが強まりました。

また、当時は「ソフトスキル系のイベントも沢山あればなぁ」と、イベントへの願望がありました。輪読会・ペアプロといった技術系のイベントは多いです。
しかし、ソフトスキル系のイベントは少数なので、私自身で開催し、盛り上げたい気持ちがありました。

2つの動機から「自主開催するぞ!」と意気込んでいましたが、恥ずかしさがあって気後れしました。
しかし、受講生に背中を押して頂いた甲斐もあり、開催する決心が着きました。

イベントの形式を1分間スピーチにした背景

開催する意を決したことで、次は形式決めです。ソフトスキルの中でも、私が苦手とする「コミュニケーション(特に言語化)」が練習できる形式にしようと決めました。

苦手を克服したい目的があったので、それが可能な仕組みを支えるコンセプトを考えました。そのコンセプトとは、

「早い段階で不確実性を減らす形式」

です。具体的には、現時点における言語化能力の課題を早期発見しやすくし、改善を繰り返せるような形式を目指しました。

特に、「早い段階」というのが私の中でポイントです。
コミュニケーション能力は、エンジニアになる上で重要度が高く、また改善に時間を要します。さらに、能力の定量的な評価が難しいため、スキル不足に気づきにくいです。
もし能力不足であることが、カジュアル面談や面接時に気づいた場合、「手遅れ」状態になることを危惧しました。

そういった経緯からコンセプトを考え、そのコンセプトを満たしやすくしたものが、1分間スピーチ会でした。

工夫したこと

工夫したことは、「相手の気持ちを考える」ことです。

これは、今回の1分間スピーチに限らず、今年1年を通して意識するようにしてました。
具体的に1分間スピーチの場合は、「参加者の負担を減らす」ことを心がけ、次のような取り組みをしました。

  • 開催時間を短くする(大体30分程度)
  • 質問は最初にwataが必ず行う
    • 質問の敷居を下げるため
    • フィードバックが得られるように、発表に対する質問数が1つ以上の状態にする
  • 事前テーマを面接の質問にする
    • 就活希望者が多いため、就活の事前準備に最適と考えた
    • 面接の質問は、課題解決や学習方法等を内省する問として最適と考えた

得られたこと

実施してみた個人的な結果は、コンセプトをそこそこ反映出来たかなと自己満足してます。
私が実施した中で、コンセプトである「早期発見した課題」と、「想定外に良かった」ことを紹介します。

  • 早期発見した課題
    • 聞き手目線を意識して話の構成を考える
    • 聞き手の興味あることは探るには、聞き手の質問内容、質問量から把握する
    • 聞き手が話を理解しやすくするために、コンテキストをなるべく合わせる
    • 1分(最大2分)は思った以上に短い。簡潔に伝えるために話したいことを絞る
    • 印象が下がるような口癖(えっと、えー)を頻繁に言わないようにする
    • うまく話せない、中身が薄い話は、そのテーマについて深く考えてない or 言語化が出来てない
  • 想定外に良かったこと
    • 発表者の話し方や学習・仕事の進め方、興味のある技術等を知る機会が増えた
    • 発表したテーマについてのディスカッションが増えた

但し、1分間スピーチも銀の弾丸ではなく、デメリットも見受けられました。

  • 話し手を主体に置いた形式のため、聞き手のメリットが薄くなりがち
    • 聞き手の興味を惹かないこと内容も多い
      • 事前テーマの面接の質問
      • 学習内容
  • 言語化の練習としては質が低い部分もある
    • 発表、質疑応答の時間が短すぎるので、詳細な話が出来ない

やってみた感想

初の試みなので、参加してもらえるのか?、続くのか?等の不安に駆られました。しかし、現在まで何とか存続できてます。
これもひとえに参加者のおかげです。来てくださった方に本当に感謝してます。

また、参加者の方が、日報やブログで書いた感想を目に見かけます。何かしらの気づきを得たり、有意義な時間を提供出来たことを知ると純粋に嬉しいです。ユーザーに喜ばれるプロダクトづくりをした感覚を味わえました。

最後に

@lef237さんのお声掛けに後押しされて、「新春 輪読会EXPO 2023」という、輪読会や勉強会を紹介するイベントで「1分間スピーチ会」を告知する予定です。人見知りなので緊張しますが、告知を頑張りたいと思います💪

コミュニケーション(言語化)が苦手な方、就活の面接練習をしたい方は、是非遊びに来てください〜!

明日の記事は、@saeyama_pgさんになります!

RailsのPathヘルパーを誤用してdeviseにハマった

はじめに

タイトル通りです。 deviseを使って認証機能を実装する際に、Pathヘルパーを誤用していたため、devise周りの機能がバグってしまいました。

この記事では、deviseでバグが発生した原因とその修正方法をまとめました。 また、何故deviseで期待した挙動にならないのかを理解するために、deviseのコードを追ってみました。

目次

バグの内容

サインアウトのリンクをクリック後に以下のバグが発生しました。

  • リダイレクトしない。
  • サインアウト後に通知メッセージが表示されない。
    • 英語の場合、en.devise.sessions.signed_outキーの訳文が該当メッセージとなる。

再現方法

今回発生した現象を再現するために、devise周りの設定方法について記載しました。

まず、バージョン情報は以下になります。

次にルーティングの設定です。config/routes.rbにて以下のように設定をします。

Rails.application.routes.draw do
  devise_for :users
  # その他ルーティングの設定は省略。
end

サインアウトのprefix、メソッド、URIパターンは以下のようになります。

prefix メソッド URIパターン アクション
destroy_user_session DELETE /users/sign_out(.:format) devise/sessions#destroy

Controllerの設定は不要です。
最後にViewの設定になります。任意のViewファイルで以下のようにPathヘルパーを使ってサインアウトのリンクを作ります。

<%= link_to 'サインアウト', destroy_user_session_path(current_user), method: :delete %>

current_userはdeviseが用意したヘルパーメソッドです。サインイン中のユーザーのモデルオブジェクトを返します。

バグ発生の原因と対処方法

バグ発生の原因は、タイトルにある通り、Pathヘルパーの使い方に誤りがあるためです。 該当箇所は再現方法で説明した「サインアウトのリンクを作成するコード」です。 Pathヘルパーのdestroy_user_session_pathのパラメータにcurrent_userを指定したことが誤りになります。

destroy_user_sessionURIパターン/user/sign_out(.:format):formatは、パラメータとなります。丸括弧()は必須ではないパラメータを指します。パラメータ名がformatなので、ここに入れるべき値は、htmlxmlなどのMimeTypeのサブタイプが当てはまります。

しかし今回のケースでは、Pathヘルパーの引数にcurrent_userを指定してます。 実際のURLは/user/sign_out.2のように、:format箇所が ログインユーザーのidとなります。フォーマットがdeviseの想定しない値であるため、今回のバグが発生したのです。

バグの対処方法は簡単です。 Pathヘルパーのパラメータに不適切な値を指定したので、これを修正することでバグは解消できます。URIパターンの丸括弧のパラメータは必須ではないため、下記のように、Pathヘルパーの引数を削除します。(引数なしでもリクエストでのフォーマットのMimeTypeのサブタイプはhtmlになります。)

<%= link_to 'サインアウト', destroy_user_session_path, method: :delete %>

サインアウト後にリダイレクトされない原因

根本的な原因は上述しましたが、deviseで期待した挙動にならない原因をコードリーディングで調査したいと思います。

サインアウト後のアクションは、devise/sessions#destroyになるので、app/controllers/devise/sessions_controller.rbdestroyメソッドを見ます。(deviseのファイルのパスは、bundle info deviseで確認が出来ます。)
最終行にrespond_to_on_destroyメソッドを呼び出しているので、このメソッドがリダイレクト処理を実行しています。

  def respond_to_on_destroy
    # We actually need to hardcode this as Rails default responder doesn't
    # support returning empty response on GET request
    respond_to do |format|
      format.all { head :no_content }
      format.any(*navigational_formats) { redirect_to after_sign_out_path_for(resource_name) }
    end
  end

respond_toメソッドでは、クライアントの要求するフォーマットごとに処理を記述しています。 ブロック処理にはformat.all(引数なし)とformat.any(*navigational_formats)があります。format.allformat.anyは異なる機能のメソッドに見えますが、これらはエイリアスメソッドになります。 ですので、2つの処理が異なる点は引数の有無です。 引数の有無による挙動の違いについては、Rails APIActionController::MimeResponds#respond_toで以下のように説明があります。

respond_to | Rails API

  • 引数有りの場合

    respond_to also allows you to specify a common block for different formats by using any:

  • 引数無しの場合

    any can also be used with no arguments, in which case it will be used for any format requested by the user:

つまり、引数有りの場合は、指定したフォーマットを対象に共通のブロック処理を実行します。また、引数がない場合は、任意のフォーマットを対象に共通のブロック処理を実行します。

respond_to_on_destroyでは、navigational_formatsに該当しないフォーマットの場合は、リクエスト時のページから遷移しない204 No Contentをレスポンスステータスとしています。 このことから、サインアウト後にリダイレクトしない原因は、destroy_user_session_path(current_user)のフォーマットがnavigational_formatsに該当しないことだと予測できます。

では次に、navigational_formatsがどのフォーマットであるのか調べます。 app/controllers/devise_controller.rbに該当するメソッドがあります。

  def navigational_formats
    @navigational_formats ||= Devise.navigational_formats.select { |format| Mime::EXTENSION_LOOKUP[format.to_s] }
  end

配列のクラス変数で繰り返し処理を行っています。Mime::EXTENSION_LOOKUPに含まれているフォーマットを抽出した要素がnavigational_formatsになるようです。

Mime::EXTENSION_LOOKUPは、Railsで扱うフォーマットになります。 クラス変数のDevise.navigational_formatsは、lib/devise.rbで定義しています。

  # Which formats should be treated as navigational.
  mattr_accessor :navigational_formats
  @@navigational_formats = ["*/*", :html]

navigational_formatsは、Railsプロジェクトで設定可能な項目になります。 つまりlib/devise.rbでは、初期設定としてワイルドカードhtmlnavigational_formatsとして定義されています。

以上のことから、調査内容をまとめます。

  1. destroy_user_session_path(current_user)のリクエストフォーマットはログイン中のユーザーidである。
  2. リクエストのフォーマットがnavigational_formatsに該当する場合は、リダイレクトする。
  3. 2.以外の場合は、204 No Contentをレスポンスステータスを返す。つまり、画面遷移をしない。
  4. navigational_formatsは、今回のdeviseの設定では*/*html
  5. 今回のケースでリダイレクトしない原因は、リクエストフォーマットがnavigational_formatsに含まれず、2.が実行されるため。

サインアウト後に通知メッセージが表示されない原因

こちらの原因もコードリーディングで調査したいと思います。

destroyメソッドには、set_flash_message!を呼び出しています。これが、通知メッセージを出力する処理となります。

  # Sets flash message if is_flashing_format? equals true
  def set_flash_message!(key, kind, options = {})
    if is_flashing_format?
      set_flash_message(key, kind, options)
    end
  end

デバッグすると、is_flashing_format?の値が偽となり処理が終了します。
is_flashing_format?を見てみます。

# Check if flash messages should be emitted. Default is to do it on
# navigational formats
def is_flashing_format?
  request.respond_to?(:flash) && is_navigational_format?
end

最初の式の値は真となりましたが、2つ目の式の値は偽となりました。 ここでもnavigational_formatが出ました。is_navigational_format?は、リクエストのフォーマットがDevise.navigatinal_formatsに含まれているかを判定します。

def is_navigational_format?
  Devise.navigational_formats.include?(request_format)
end

Devise.navigational_formatsはリダイレクトの原因調査の際に確認しました。 つまり、通知メッセージが表示されない原因は、リダイレクトしない原因と同様にリクエストフォーマットがnavigational_formatsに含まれないためです。

まとめ

RailsのPathヘルパーを使用する際に、フォーマットのパラメータにdeviseが意図しない値を指定したことが原因でdevise周りの機能でバグが発生しました。
今回のバグの調査をしたことで、提供されるAPIメソッドの使い方と挙動を正確に理解することが重要であることを学びました。

「getter,setter (attr)」と 「インスタンス変数の直接アクセス」を比較してみた(書き方編)

はじめに

こんにちは、Webエンジニアを目指してフィヨルドブートキャンプ(FBC)で学習をしてますwataです。

ここ最近は、FBCのカリキュラム以外にも、リファクタリング*1 *2を学習中です。

リファクタリング本には、対立関係の項目が目立ちます。例えば、「引数の追加」と「引数の削除」です。 この対立関係において筆者は、片方の方法が絶対的な最適解ではない、つまり銀の弾丸ではないことを主張してます。

その一方で、私がコーディングをする際に使用しない項目も散見されます。本を読むだけだと、「本当にこの項目は、リファクタリングをするほどの価値があるのかな🤔」と思うことがあります。

そこで私が気になった、対立関係にある項目を、自分なりに比較してみることにしました。 対象は、タイトルにある「getter、setter」と「インスタンス変数の直接アクセス」を扱います。

本記事では前段として、Rubyでの「getter、setter」の書き方を紹介したいと思います。

インスタンス変数の直接アクセス

インスタンス変数を直接操作する方法は、ローカル変数とさほど変わりません。 インスタンス変数の先頭に@を付けるだけです。簡単ですね!

サンプルコードにNameクラスを用います。名前(first_str)と名字(last_str)を引数に取ります。

class Name
  def initialize(first_str, last_str)
    # インスタンス変数へ直接代入する
    @first = first_str
    @last = last_str
  end

  def to_s
    # インスタンス変数を直接参照する
    "#{@last} #{@first}"
  end
end

getter、setter

前提として、今回取り上げるgetter、setterは、クラス内部に限った話をします。 何故ならRubyは、「インスタンス変数の直接アクセス」がクラス外部から出来ないためです。

getter、setterとは、インスタンス変数をメソッド経由でアクセスする方法です。 皆さんお馴染みのアクセスメソッド(attr_readerattr_writeattr_accessor)もgetter、setterになります。

注意点として、getter、setterはインスタンス変数を加工するようなロジックは含みません。あくまで値を取り出す、値を代入するだけです。

  # getter
  def first
    @first
  end
  # getterでない
  def uppercased_first
     @first.upcase
  end

リファクタリング本では、インスタンス変数自身をメソッドでカプセル化することから、フィールドの自己カプセル化自己カプセル化フィールドなどと呼称してます。

定義方法

getter、setterの定義方法について説明します。定義方法は「自前実装」と「アクセスメソッド」の2パターンがあります。

自前実装

後述するアクセスメソッドを用いずに自前で実装する方法です。とは言ったものの、コード量は少なく実装方法は簡単です。

class Name
  def initialize(first_str, last_str)
    @first = first_str
    @last = last_str
  end

  # getter
  def first
    @first
  end

  # setter
  def first=(first_str)
    @first = first_str
  end
end

# @lastについては省略

アクセスメソッド

アクセスメソッドの「attr_readerattr_writerattr_accessor」を使用します。自前での実装と比較して、シンプルに定義ができます。
3つのメソッドは、アクセスする用途ごとに使い分けます。

以下のコードは、自前実装で作成したgetter、setterをattr_accessorに置き換えたものになります。 attr_accessorメソッドを呼び出すだけです。

class Name
  attr_accessor :first, :last

  def initialize(first_str, last_str)
    @first = first_str
    @last = last_str
  end
end

呼び出し方

呼び出し方は、自前実装とアクセスメソッドで共通です。アクセスメソッドが自前実装を定義しているためです。 getterメソッドの呼び出し方法は、通常の関数の呼び出し方法と同様です。

class Name
  attr_accessor :first, :last

  def initialize(first_str, last_str)
    @first = first_str
    @last = last_str
  end

  def uppercased_first
    # getterでの呼び出し
    first.upcase
  end
end

name = Name.new('wata', 'fjord')
# 大文字に変換される
name.uppercased_first #=> WATA

ここで注意すべきなのが、setterです!
インスタンス変数の初期化を、以下のようなsetterメソッドの呼び出し方法で置き換えてみます。

class Name
  attr_accessor :first, :last

  def initialize(first_str, last_str)
    first = first_str # setterのつもりで呼び出している
    @last = last_str
  end

  def uppercased_first
    first.uppcase
  end
end

name = Name.new('wata', 'fjord')
# 大文字に変換されない!
name.uppercased_first #=> undefined method `uppcase' for nil:NilClass (NoMethodError)

newメソッドの第1引数(first_str)に文字列'wata'を与えたのに、uppercased_firstでは例外が発生しました。。。
実は、初期化で呼び出したはずのfirstはsetterメソッドではないんです。firstローカル変数として解釈されます。 ローカル変数について、るりまの説明では以下のように書かれています。

変数と定数 (Ruby 3.1 リファレンスマニュアル)

小文字で始まる識別子への最初の代入はそのスコープに属するローカル変数の宣言になります。

そのためinitializeメソッド内では、ローカル変数firstの値は'wata'ですが、インスタンス変数@firstの値はnilです。

class Name
  def initialize(first_str, last_str)
    first = first_str #=> "wata"
    @last = last_str
    p @first #=> nil
  end
end
name = Name.new('wata', 'fjord')

name.uppercased_firstメソッドを呼び出したのに例外が発生した理由は、 @firstに文字列"wata"が代入されず、値がnilの状態でuppcaseメソッドを呼び出したためです。

この問題を解消するために、クラス内部でsetterメソッドを呼び出すためにselfを用います。selfは実行中のメソッドのレシーバーになるオブジェクトを指します。
オブジェクト.メソッド名と記述することで明示的にメソッドであることを伝えます。

class Name
  attr_accessor :first, :last

  def initialize(first_str, last_str)
    self.first = first_str # setterを利用
    @last = last_str
  end

  def uppercased_first
    first.upcase
  end
end

name = Name.new('wata', 'fjord')
name.uppercased_first #=> 'WATA'

getter、setterのアクセスをprivateにする方法

インスタンス変数への操作方法にgetter、setterを紹介しました。

しかし、上述で紹介した方法は、クラス外部からのアクセスが可能になってしまいます。アクセスするスコープをクラス内部限定にしたい場合もあると思います。
その場合は、privateメソッドを呼び出します。 privateメソッド以降に定義したメソッドは、クラス内のみのアクセスになります。

class Name

  def initialize(first_val, last_val)
    @first = first_val
    @last = last_val
  end

  def lowercased_first
    # クラス内部からのアクセス
    first.downcase
  end

  private   
  attr_accessor :first, :last
end

name = Name.new('wata', 'fjord')
name.lowercased_first #=> 'WATA'
# クラス外部からのアクセスは不可能
name.first #=> private method `first' called for #<Name:0x00007fe7d70aae30 @first="wata", @last="fjord"> (NoMethodError)

1つ注意点として、privateメソッドはJavaC#privateメソッドとは異なる仕様があります。この点は、伊藤さんの記事が詳しいのでこちらを参照して下さい。

blog.jnito.com

また、チェリー本にはprivateメソッドの細かい使い方が解説していあります。

まとめ

今回はリファクタリング本の対立関係「getter、setter」と「インスタンス変数の直接アクセス」を比較する前段として、書き方を紹介しました。

書き方だけを比較すると、「インスタンス変数の直接アクセス」の方が簡易的なので、これ一択のようにも思えます。 しかし、リファクタリングのし易さや可読性の観点から見ると、一概にベストな選択とは言い切れなさそうです。

次回は、書き方以外の観点で比較してます!