Ruby
Rails

RailsのController-Viewのエラー表現は返り値を意識しないと色々困るのでメモした

Rubyの返り値って

rubyは最後に評価された値を返り値にしていますが、静的型付け言語ではほとんどの場合返り値を指定してメソッドを作ります。

public int sum(int a, int b){ return a + b }
public fun sum(a: Int, b: Int): Int = a + b

rubyの場合わざわざ返り値の型を宣言しなくていいので、そこを意識するとRailsでいうところの
ControllerからViewまでのエラー表現がシンプルになりそうということのメモです.

def sum(a, b)
  ...
end

RailsにおけるControllerの処理系のパターン

更新系の処理に絞っています。
また、@modelが保存できるかという条件分岐を分けるのがsaveというメソッドが true or false になるという以下の処理系がScaffoldでも採用されています。

  • 綺麗に済む一般的なcrudのパターン
def create
  @model = Model.new(create_params)
  if @model.save
    redirect_to ...........
  else
    render :new
  end
end

### View
  - @model.errors.full_messages.each do |msg|
    %p.error= msg

なんらか複雑な、トランザクションを含んだ処理型では例外を発生させて、発生しうる例外を
捕捉するようにしています。

  • 例外を発生させて捕捉するパターン
  def create
    @model = Model.new(create_params)
    Model.transaction do
      @model.save!
      @model.store_image!
      redirect_to ...........
    end
  rescue 例外ABCD
    render :new
  end

### View
  - @model.errors.full_messages.each do |msg|
    %p= msg

エラーメッセージなのですが、問題があった操作に対しては@model.errorsに含まれるエラーメッセージを表示するというのが一般的でしょう。

ただ、捕捉すべき例外は削除されたり、増えたりする可能性があります。
最近ではこのあたりです 。https://qiita.com/ryohashimoto/items/a8ba7bb8d6f340894188#activerecordnotnullviolation

必要が無いなら分岐を作る場合はなるべくtrue or falseで分岐を作ってあげるのが良さそうです。

Viewには@modelのerrorsを渡せば正規化されたエラーの原因が
分かるのですが、store_image!で発生し得る例外を幾つか考えてみなければいけません。
なので、@modelのerrorsに加えて、想定し得ない例外をユーザーに提示する場合このようになります。

  def create
    @model = Model.new(create_params)
    Model.transaction do
      @model.save!
      @model.store_image!
      redirect_to ...........
    end
  rescue 例外ABCD => e
    flash[:alart].now = e.message
    render :new
  end
### View
  - @model.errors.full_messages.each do |msg|
    %p= msg
  = flash[:alert]

途端に怪しくなりました。

flashと@modelのエラーのそれぞれをviewでユーザーに見せるような形になっています。
加えて、素の例外のメッセージは大方英語で、ユーザーが理解できないような形になります。(例えなので普通そんなメッセージはユーザーに見せませんが、、)

単純に全てをrescueするのは把握できない理由も含めて失敗したと捉えるもので、
例外の原因を探るために一手間入れないと行けなくなります。

例えばユーザー向けのメッセージは単一にして、開発者には例外通知のメールを送信するなどです。

  rescue => e
    ExceptionNotifier.notify_exception(e)
    flash[:alart].now = "何らかのエラーが発生しました"
    render :new

ただ、こういったことを続けていても、場当たり的にこのような処理を色々な箇所に追加することになってしまうので
基本的には捕捉できる例外は捕捉して、捕捉し得ない例外にだけ自動で例外通知を送るように
するのが自明だと考えています。

つまり捕捉できる例外を開発者が調べて都度追加する必要があるのではないかということを考えると、
なるべくControllerの分岐処理はtrue or falseで判断できる形がいいということでした。

それでも例外処理が必要になる場合

ひとつのアクションで複数の事をしようとした場合に例外が発生しうるケースはほとんどのシステムで
そのようなケースはあります。
例外のメッセージが@modelのエラーに含まれる形になっていないため、
Controllerから具体的な処理を別のクラスにしてエラーをまとめたいと思いました。

Controllerに@errorsをupdaterの返り値で渡して true or falseの形にまとめるようにしました。

作成したPOROからは、どのようなケースでも最後に@errorsを返すようにしています。
エラーがなければ、Viewに表示されるエラーメッセージは無く、
逆は先述の@modelのerrorsに加えて、想定し得ない例外をユーザーに提示する形です。

結果、Controllerの@errorがupdaterに収められているかで条件分岐をif式で行う事ができ、
Viewに表示するエラーも@errorsにまとめることができました。

# Controller
  def create
    @model = Model.new(create_params)
    updater = ModelUpdater.new(@model)

    if (@errors = updater.run).present?
      redirect_to ...........
    else
      render :new
    end
  end

# PORO
class ModelUpdater
  def initialize(model)
    @model = model
    @errors = []
  end

  def run
    Teacher.transaction do
      @model.save!
      @model.store_image!
    end
    @errors
  rescue 例外ABCD => e
    @errors.push e.message
    @model.errors.full_messages | @errors # @errorsの配列を返す
  end
end

# Viewのエラー表示
- @errors.each do |err|
  %p err

ただ、このケースだと@errorsなるインスタンス変数を余計にControllerに持たせることになるため
テストを少し多めに書かなければなりそうです。

シンプルに以下のような形でエラーを表出させることができないかを考えて見たのですが、

  - @model.errors.full_messages.each do |msg|
    %p= msg

例外のメッセージ含めそのままerrors.add(:base, :some_error)等としてしまいActiveModel|Recordのエラーとして
しまうのが個人的には良さそうな方法でした。

# PORO
class ModelUpdater

  def initialize(model)
    @model = model
  end

  def run
    Teacher.transaction do
      @model.save!
      @model.store_image!
    end
    true #処理を駆け抜けたらtrueを返す
  rescue 例外ABCD => e
    @model.errors.add(:base, e.class.to_s.underscore)
    false # 失敗したらfalseを返す
  end
end

# Locale
ja:
  activerecord:
    errors:
      models:
        model:
          attributes:
            base:
              some_error_a: "some_errorA occurred"
              some_error_b: "some_errorB occurred"
              some_error_c: "some_errorC occurred"
              some_error_d: "some_errorD occurred"
# View
    - @model.errors.full_messages.each do |msg|
    %p= msg

これによりControllerは以下のようにtrue or falseによる分岐処理に専念させることができ、
Viewにおけるエラー表示も正規化することができました。

# Controller
  def create
    @model = Model.new(create_params)
    updater = ModelUpdater.new(@model)

    if updater.run
      redirect_to ...........
    else
      render :new
    end
  end

まとめ

使う側、使われる側における条件分岐をtrue or falseに仕向けること、
使う側が気にするべき状態を使われる側から減らしてやることで処理をシンプルにできました。

言いたいこと自体は、使われる側が使う側を楽にハンドリングさせるために返り値を正規化してあげれば楽になれる箇所があるということだったのですが、エラーメッセージのハンドリングに終始してしまいました。。

スコープが多岐に渡る変数が氾濫していると、どこで何のために使われるものなのかが
見づらくなりますし、他のアクションではまた別の実装方法で@errorsをもたせたりなど、
基本的な事なのですが関連するプロジェクトで見受けられたのでまとめました。

最終的な例では主体となるActiveRecord|ActiveModelインスタンス(@model)を継承したクラスインスタンスを想定してerrors.addとしていますが、
これがない場合以下のようなカスタムエラーオブジェクトを返却してもいいかもしれません。

エラーメッセージの表現に関してActiveRecord|ActiveModelの恩恵に授かるのが今のところ楽な感触でした。

class SomeErrorModel
  include ActiveModel::Model
  delegate :full_messages, to: :errors
  ERRORS = [:some_error_a, :some_error_b, :some_error_c, :some_error_d].freeze

  def add(code)
    raise ArgumentError unless code.in?(ERRORS)
    errors.add(:base, code)
  end
end

ピュアなRubyなら以下のような形で、色々な箇所から共用できるようなエラーを表現できるインスタンスをクラス化しておけば汎用的に使えそうです(apiとかバッチ処理とか)

class SomeErrorModel
  attr_reader :errors
  ERRORS = {
    ok:      {code: :E_A, msg: "error a occured"},
    invalid: {code: :E_B, msg: "error b occured"},
    taken:   {code: :E_C, msg: "error c occured"}
  }.freeze

  def add(code)
    raise ArgumentError unless code.in?(ERRORS.keys)
    erros << ERRORS[code]
  end

  def full_messages
    erros.map{|h| h[:msg]}.uniq
  end
end