dry-operationのススメとエラー情報をViewまで持っていく方法の模索

今回は Ruby で Web アプリっぽい何かの話。Rails や Sinatra を想定していて、いくつかの処理でエラーが発生する可能性があり、それをいい具合に例外を使わずにフロー制御し、View まで一貫して伝える方法を考える。

エラー処理を書きたくないし、例外では潔く終了して監視されてほしい

エラー処理って面倒くさい。だいたいエラーってなんだ。目の前のソレ、別にエラーじゃなくてただの False や Nil や String じゃないですか? あるいはただの ActiveModel では? エラーとはいったいなんですか?

以前、

少しでも例外を安全に扱うために - RubyとJavaScript編 - (2024-08-10) | あーありがち

で扱っていたのは例外で、「少しでも」というアプローチなんだけど、今回は「本当は例外を投げて例外を捕捉する記述そのものを書きたくない」という話。例外を使わずにアプリケーションエラーの情報を伝播させ、例外は素直に死んで監視システムに捕捉されて通知されてほしい。

ActiveModel::Errorsじゃだめなんですか?

ActiveModel::Errors はやはり ActiveRecord のために作られたものであり、

複数の値からなるモデル的な何かが valid か invalid かを表すのに向いている

ものである。例えばそういういくつかの値を組み合わせて、新しい値を生成する処理の結果がエラーか否かを表現する、あるいは複数のモデル間のやりとりの結果がエラーかどうかを表すには向いているとは言い難い。(使えなくもないけど)

また、別に ActiveModel::Errors はフローの制御については支援はない。エラー状態のオブジェクトを持ったまま View まで持っていきやすいだけである。

モナドか? モナドなのか?

なんとなくモナドが正解っぽいと感じつつ、実はもう何年も「モナドってなんだよ、よく分かんねーよ」と苦手意識を持っていたので、存在自体は知っていながらちょっと距離を置いていた

dry-rb - dry-monads - Introduction

を調べていたら

dry-rb - dry-operation - Introduction

を知った。

Dry::Monads::Result という Success オブジェクトあるいは Failure オブジェクトを使いつつ、エラーハンドリングについては Operation 任せにできる嬉しいレールのように見える。

Operationの書き方と動作

class SomeOperation < Dry::Operation
  def call(args)
    val1 = step <Resultを返すメソッド>
    val2 = step <Resultを返すメソッド>
    step <Resultを返すメソッド>
  end
end

※ いったん Result が何かは無視して大枠の形だけ捉えてみる。

  • Dry::Operation を継承したクラスを定義
  • call メソッドの中に処理を書く
  • call メソッドは最終的に Dry::Monads::Result を返す
  • Operation の call メソッドの中では step メソッドを利用でき、step メソッドは「Dry::Monads::Result を返すメソッド」を受け取る
    • このメソッドの作り方はなんでもよい。Operation class の中にメソッドを定義してもよいし、外部のクラスでもよい。つまり、他の Operation でもよい。
  • step メソッドは Success を受け取った場合、value! を返すので上記の例だと val1 は通常の値になる(Result ではない)
  • step メソッドは Failure を受け取った場合、内部的に専用の例外を上げ、一気に脱出しつつ、その Failure を返す

以上のように

Dry::Monads::Result だけを意識して、エラー時の分岐を気にすることなく、ハッピーパスを書き連ねていくことができる

なんと。「Result 型を導入してもけっきょく繊細な分岐を書かなきゃいけないんだよなぁ」と諦めていたのだが、そんな心配は吹っ飛んでしまった。

ポイントは Dry::Monads::Result である。

  • call 全体は Result を返す
  • step は Result を返すメソッドを受け取る
  • Operation 全体では Success 時の値そのものをハッピーパスで扱えばよい

実にシンプル。

Dry::Monads::Resultってなに

ではそんなキーポイントの Dry::Monads::Result とは何なのか。これは

  • 成功を表す Success オブジェクト(Success() がコンストラクタ代わり)
  • 失敗を表す Failure オブジェクト(同上)

のいずれかのことを言う。いわゆる最近流行の Result 型のことだと思っておいて特に問題ない。Ruby なので型ではなく Result オブジェクトである。

これだけだと乱暴なので、少し公式のサンプルを見てみよう。

  def find_user(id)
    user = User.find_by(id: id)

    if user
      Success(user)
    else
      Failure(:user_not_found)
    end
  end

恐らく ActiveRecord の find_by を利用している。

該当レコードが存在しなかった場合、find と異なり例外は発生しないが、nil が返ってきてしまう。nil は危険だ。そこで Failure() を返す。こうすると、便利な各種メソッドを利用することができ、エラーであることを明示し、その内容も収めることができる。nil には存在しない豊富な情報も手に入り、安全にコードを書き続けることができる。

これが Result の効能である。

Operationの使い方

result = SomeOperation.new.call(params)
  • とにかく戻り値は Dry::Monads::Result になる
  • Failure の中の構造は自由

エラーの情報をいかにViewに持っていくか

Ruby で Web アプリで考える場合、いちばん使われるのはやはり ActiveModel::Errors だと思う。フォームオブジェクトや ActiveRecord などは ActiveModel::Errors を持っていると考えてよい。一方で内部的な処理の結果のエラー情報を Dry::Monads::Result で返す場合、

ActiveModel::Errors と異なる構造のエラーが渡ってくることになる

そこで、

  • View に渡す情報の構造を Web API のレスポンスの構造のアナロジーで考える
  • Dry シリーズを使うコードのエラーの情報の持ち方も Dry シリーズの何かに寄せる
  • View では ActiveModel::Errors と上記の Dry シリーズのエラーの持ち方の両方に対応した記述を行う

と考えて実装してみたのが以下の例である。

dry-operation + dry-monads + ActiveModelほかいくつかの処理を交えていい具合にViewに返す試み · GitHub

  • validate_input では Form として適切かどうかの validation を行っている(該当モデルのコードはないけど想像で補完してほしい)
  • 他は外部のクラスを利用している。EmailReachableMagicLink である

EmailReachable は恐らくドメインが実在しているかどうか、MX レコードを引けるかどうかを確認する。MagicLink はこのサイト固有の magic link を生成する処理が含まれている。いずれも中身はなんとなく想像で補完してほしい。その中身は重要ではない。ポイントはいずれもちゃんと Dry::Monads::Result を返すようになっていること、それだけである。

ただし、Failure の中には Dry::Schema::Message 相当のエラー情報が含まれていることを期待している。こうしておくことで、

  • Controller ( Action ) はとにかく Operation に受け取った値を投げる
  • Result が渡ってくるのでその結果を見て適切に所定の場所に値を収める
    • この場合はエラーがあった場合は errors に値を、ない場合は data に値を収めている
  • View では data の中身がある場合は正常系の UI を、errors の中身がある場合はエラーメッセージを表示

と書くことができる。値を収める場所の振り分けや View の出し分けはしているが、フローそのものは分岐を書かずに済ますことができている。これ以上細かく制御したい場合は制御すればいいが、基本的にはこの型に嵌めて書いていけば破綻はしない。

どうだろう。モデル部分、Result を返す部分はしっかり書きつつ、全体のフローに関してはだいぶ楽できそうな感じがしないだろうか。

おまけ - interactorを置き換えてよいかも -

直接 interactor gem を紹介したことは今までないんだけど、interactor については何回か取り上げている。

このうち interactor gem を利用したコードは以下のようになる。

class SomeOperation
  include Interactor

  def call
    process = SomeProcess.new(context.foo)
    process.call

    if process.errors.empty?
      context.val = process.val
    else
      context.fail!
    end
  end
end

result = SomeOperation.call(params)
if resuilt.success?
  ..
else
  ..
end

いわゆる Service クラス的な雰囲気を持つ、Operation と似た感じのコードではあるが、

  • interactor の戻り値は result 型のような何かで、result.success? のような便利メソッドがあり、情報はリッチ

ただし、制約がいろいろあって、

  • call の定義側は args を直接受け取れない。Context から受け取る1
  • 戻り値も Context に返す
  • 呼び出し側はインスタンス生成できない、つまりコンストラクタインジェクションはできない2。すべて Context を利用する

Context を利用していることで interactor の合成用の organize が DSL 的に可能になるのだが、一方で通常の class のようにコンストラクタを利用できないし、値の受け取り方も独特になっており、時間の経ったコードを読み解く、あるいは新たに書くのにやや難儀することがあった。

またコンストラクタインジェクションが利用できないので、以下のようなコードも書けない。

class SomeOperattion
  def initialize(val1: ENV["FOO], val2: ENV["BAR"])
    ..
  end
  attr_reader :val1, :val2

  def call
  end
end

環境変数の値をコンストラクタのデフォルト引数で受け取る典型的なコンストラクタインジェクションだが、例えばこういうことができれば通常は環境変数から API サーバの URL を受け取るようにしておき、テストの時だけ差し替えることが簡単に行えるのだが、interactor gem ではこういう使い方ができず、常に call に値を渡す必要があるため、テストを考えると、コンストラクタ以外の初期化用のメソッドだったり、interactor に渡す値を整えて interactor の実行結果を返すような wrapper メソッドが欲しくなる。

dry-operation ではこのようなことはなく、(実際の実行はもっと複雑だが)素直に普通の class のように定義できる。

また、Result についても interactor の result は interactor でしか利用できないが、Dry::Monads::Result は単体でも利用できる。Result は Result で恩恵があるため、この点でも Dry シリーズに寄せておくのは得策に思える。

Dry::Monads::Result を使うと決めてしまうのであれば、乗り換えはむしろ良い点が多い。

  1. 定義側はインスタンスメソッドを定義しているのに呼び出し側はクラスメソッドを呼んでいることに注意 

  2. 内部的にはコンストラクは Context の生成に予約されている 

More