Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

Dry Monadsで実現するRailway Oriented Programming(鉄道指向プログラミング)入門

エラーハンドリングは、あらゆるプログラムにおいて不可欠な要素です。コードの実装過程でどのようなエラーが発生しうるかを事前に想定し、それぞれのエラーが「どの段階で」「なぜ」発生したのかを適切に説明できる出力を返すことが重要です。しかし同時に、その実装によってコードの可読性や保守性が損なわれては本末転倒です。

まずは、多くの方が抱くであろう疑問から始めましょう。Railway Oriented Programming(鉄道指向プログラミング)とは何なのでしょうか?

Railway Oriented Programming(ROP)とは

ある目的を達成する関数は、より小さな関数の組み合わせ(合成)として表現できることがあります。これらの関数はそれぞれ異なるステップを担当し、それらが連鎖することで最終的なゴールに到達します。例えば、「ユーザーの住所をデータベースで更新し、その変更をユーザーに通知する」という関数は、次のようなステップで構成されるでしょう。

validate user -> update address -> send mail upon successful update

ここで重要なのは、各ステップが成功するか失敗するかのいずれかであり、一つでも失敗すればプロセス全体が失敗するという点です。目的が達成されないためです。

Railway Oriented Programming(ROP)は、Scott Wlaschin氏が考案した用語で、このような関数におけるエラーハンドリングに「鉄道のポイント(分岐器)」のアナロジーを適用したものです。鉄道のポイントは、列車をある線路から別の線路へ誘導します。Scott氏はこの比喩を、各ステップの成功/失敗の出力がまさにポイントのように機能し、処理を「成功の線路」か「失敗の線路」のどちらかへ切り替える、という意味で用いています。

Dry Monadsで実現するRailway Oriented Programming(鉄道指向プログラミング)入門 成功/失敗の出力が鉄道のポイント(分岐器)として働くイメージ

どこかのステップでエラーが発生すると、失敗の出力によって失敗の線路へ移動し、残りのステップはすべてバイパスされます。一方、成功の出力が得られた場合は、次のステップの入力につながり、最終目的地へと向かいます。下図のようなイメージです。

Dry Monadsで実現するRailway Oriented Programming(鉄道指向プログラミング)入門 複数ステップの成功/失敗出力が連結された様子

この「二つの線路」というアナロジーこそが、Railway Oriented Programmingの核心です。プロセスを構成するすべてのメソッドが、必ず成功/失敗の出力を返すことで、一つのステップでの失敗が即座にプロセス全体の失敗として扱われます。全体の成功は、すべてのステップが成功して初めて達成されるのです。

日常生活にたとえると

もう少し身近な例で考えてみましょう。The Milk Shakersという店名の店舗まで出向き、牛乳1パックを購入することを目標とした場合、必要なステップはおそらく次のようになります。

Leave your house -> Arrive at The Milk Shakers -> Pick up a carton of milk -> Pay for the carton of milk

家を出られなければ、最初のステップが失敗した時点でプロセス全体が失敗です。では、家を出てWalmartに行ってしまったらどうでしょう?指定された店舗ではないため、やはりプロセスは失敗です。Walmartでも牛乳が買えるからといって、プロセスが続行されることはありません。ROPはWalmartの時点で処理を中断し、「指定された店舗がThe Milk Shakersではなかったため失敗した」という失敗の出力を返します。逆に正しい店舗に行けていれば、各ステップの出力を確認しながら処理が続行され、完了または次のステップへ進みます。

このようにROPを使えば、個々のステップをつなぐif/elsereturn文の乱立なしに、読みやすくエレガントなエラーハンドリングを効率的に実現できます。

Railsでは、Dry Monadsというgemを使うことで、この二線路方式の出力を実現できます。

Dry Monadsの紹介と仕組み

モナドはもともと数学の概念です。簡単に言えば、いくつかの特殊な関数の合成・抽象化であり、コード内で利用することで状態を持つ値(ステートフルな値)の明示的な取り扱いを排除できます。また、プログラムロジックに必要な定型的な計算処理(ボイラープレート)を抽象化するものでもあります。ステートフルな値とは特定の関数にローカルではない値のことで、入力、グローバル変数、出力などが該当します。モナドはbind関数を持ち、これらの値をモナドから別のモナドへ受け渡すことを可能にします。そのため、値が明示的に扱われることはありません。モナドは例外処理、コミットのロールバック、リトライ処理などを担うようにも構築できます。

公式ドキュメント(一読をおすすめします)によれば、dry-monadsはRuby向けの一般的なモナドのコレクションです。モナドを使うと、エラーや例外のハンドリング、関数のチェーンをエレガントに記述でき、無数のif/elseに頼ることなく、望むエラーハンドリングを備えた理解しやすいコードになります。本記事では、前述の成功/失敗の出力を実現するのにまさにうってつけのResultモナドに焦点を当てます。

それでは、次のコマンドでrailway-appという新しいRailsアプリを作成しましょう。

rails new railway-app -T

コマンドの-Tはテストフォルダの生成をスキップするオプションです。テストにはRSpecを使用するためです。

次に、Gemfileに必要なgemを追加します。成功/失敗の結果を扱うためにgem 'dry-monads'を、テストフレームワークとしてgem 'rspec-rails'をtestグループとdevelopmentグループに追加してください。追加後、bundle installを実行してgemをインストールします。さらに、テストファイルとヘルパーを生成するために、次のコマンドを実行します。

rails generate rspec:install

処理を複数のステップに分割する

関数は、最終目標を達成するために協調動作する小さなメソッド群に分割するのが良い設計とされています。各メソッドから返されるエラーがあれば、プロセスのどの部分が失敗したのかを正確に特定でき、コードもクリーンで読みやすい状態を保てます。

できるだけシンプルに説明するため、トヨタ自動車のディーラークラスを作成してみましょう。このディーラーは、以下の条件がすべて満たされた場合にのみ、ユーザーへ車を配送します。

  • 製造年が2000年以降であること
  • 要求されたモデルが在庫にあること
  • 要求されたカラーが在庫にあること
  • 配送先の都市が近隣都市リストに含まれること
  • 配送予定のメッセージを送信すること

ステップが固まったところで、実際のコードを見ていきましょう。

成功/失敗の出力結果を組み込む

app/modelsフォルダにcar_dealership.rbというファイルを作成し、必要な情報でクラスを初期化します。ファイルの先頭でdry/monadsをrequireし、クラス名の直後にDry::Monads[:result, :do]をincludeします。これにより、Resultモナドと、do記法(yieldキーワードを使って複数のモナディックな操作を組み合わせることを可能にする記法)が使えるようになります。

require 'dry/monads'

class CarDealership

include Dry::Monads[:result, :do]

  def initialize
    @available_models = %w[Avalon Camry Corolla Venza]
    @available_colors = %w[red black blue white]
    @nearby_cities = %w[Austin Chicago Seattle]
  end
end

続いて、他のすべてのステップを包含し、全ステップが成功した場合に成功メッセージを返すdeliver_carメソッドを追加します。各ステップをyieldでつなぐ(バインドする)ことで、どれか一つのステップが失敗メッセージを返せば、それがそのままdeliver_carメソッド全体の失敗メッセージとなり、成功した場合はリスト上の次のステップの呼び出しへと処理が委譲されます。

def deliver_car(year,model,color,city)
  yield check_year(year)
  yield check_model(model)
  yield check_city(city)
  yield check_color(color)

  Success("A #{color} #{year} Toyota #{model} will be delivered to #{city}")
end

次に、残りのメソッドをすべて追加し、それぞれのチェック結果に応じて成功/失敗の結果を返すようにします。

def check_year(year)
  year < 2000 ? Failure("We have no cars manufactured in year #{year}") : Success('Cars of this year are available')
end

def check_model(model)
  @available_models.include?(model) ? Success('Model available') : Failure('The model requested is unavailable')
end
def check_color(color)
  @available_colors.include?(color) ? Success('This color is available') : Failure("Color #{color} is unavailable")
end

def check_city(city)
  @nearby_cities.include?(city) ? Success("Car deliverable to #{city}") : Failure('Apologies, we cannot deliver to this city')
end

これでクラスと必要なメソッドがすべて揃いました。実際にどう動作するのか、クラスの新しいインスタンスを作成し、さまざまな引数でdeliver_carメソッドを呼び出して確認してみましょう。

good_dealer = CarDealership.new

good_dealer.deliver_car(1990, 'Venza', 'red', 'Austin')
#Failure("We have no cars manufactured in year 1990")

good_dealer.deliver_car(2005, 'Rav4', 'red', 'Austin')
#Failure("The model requested is unavailable")

good_dealer.deliver_car(2005, 'Venza', 'yellow', 'Austin')
#Failure("Color yellow is unavailable")

good_dealer.deliver_car(2000, 'Venza', 'red', 'Surrey')
#Failure("Apologies, we cannot deliver to this city")

good_dealer.deliver_car(2000, 'Avalon', 'blue', 'Austin')
#Success("A blue 2000 Toyota Avalon will be delivered to Austin")

上記のとおり、deliver_carメソッドの失敗結果は、どのメソッドで失敗したかによって変わります。失敗したメソッドの失敗メッセージがそのまま全体の失敗となり、すべてのメソッドが成功した場合にのみ、独自の成功結果が返されます。また忘れてならないのは、これらのステップがそれぞれ独立したメソッドであり、deliver_carメソッドとは独立に単体で呼び出せるという点です。以下に例を示します。

good_dealer.check_color('wine')
#Failure("Color wine is unavailable")

good_dealer.check_model('Camry')
#Success('Model available')

RSpecによるテスト

上記のコードをテストするには、specフォルダ内のspec/modelsパスにcar_dealership_spec.rbファイルを作成します。1行目でrails_helperをrequireします。まずは失敗ケースのテストを書き、その後に成功ケースを書いていきます。

require 'rails_helper'

describe CarDealership do
  describe "#deliver_car" do
    let(:toyota_dealer) { CarDealership.new }
    context "failure" do
      it "does not deliver a car with the year less than 2000" do
        delivery = toyota_dealer.deliver_car(1990, 'Venza', 'red', 'Austin')
        expect(delivery.success).to eq nil
        expect(delivery.failure).to eq 'We have no cars manufactured in year 1990'
      end

      it "does not deliver a car with an unavailable color" do
        delivery = toyota_dealer.deliver_car(2005, 'Venza', 'yellow', 'Austin')
        expect(delivery.success).to eq nil
        expect(delivery.failure).to eq 'Color yellow is unavailable'
      end
    end
  end
end

上記のとおり、result.failureresult.successを使って、失敗結果や成功結果にアクセスできます。成功ケースのテストは次のようになります。

context "success" do
  it "delivers a car when all conditions are met" do
    delivery = toyota_dealer.deliver_car(2000, 'Avalon', 'blue', 'Austin')
    expect(delivery.success).to eq 'A blue 2000 Toyota Avalon will be delivered to Austin'
    expect(delivery.failure).to eq nil
  end
end

あとは、deliver_carメソッドに渡す引数を変えることで、失敗コンテキストのテストを自由に追加できます。さらに、無効な引数が渡された場合(例:year変数に文字列が渡された場合など)のバリデーションをコード側に追加するのも良い練習になるでしょう。ターミナルでbundle exec rspecを実行するとテストが走り、すべてのテストがパスすることが確認できます。

なお、メソッドの出力として成功と失敗が同時に存在することはないため、テストで両方を同時に検証する必要は基本的にありません。ここでは説明のために、失敗結果があるときに成功結果がどのような値になるか(そしてその逆)を示す目的で両方を書いています。

まとめ

本記事は、dry-monadsの紹介と、アプリケーションでRailway Oriented Programmingを実現する方法の入門編でした。ここで得た基礎的な理解は、より複雑な操作やトランザクションにも応用できます。見てきたように、ROPを使えばクリーンで可読性の高いコードが実現できるだけでなく、エラーハンドリングも詳細かつストレスフリーになります。プロセスを構成する各メソッドには、簡潔な成功/失敗メッセージを必ず添えるようにしましょう。そうすることで、エラーが「どこで」「なぜ」発生したのかを素早く特定できるようになります。ROPについてさらに学びたい方は、Scott Wlaschin氏によるプレゼンテーション動画を視聴することをおすすめします。

  1. RailsでTailwind CSSを使う方法|導入から実践的なスタイリングまで徹底解説

    CSSは魔法のような存在ですが、同時に時間のかかる作業でもあります。美しく、機能的で、アクセシブルなサイトは使っていて心地よいものですが、自分でCSSを一から書くのは骨の折れる仕事です。近年はBootstrapをはじめとする多くのCSSフレームワークが登場し、その中でもTailwind CSSは特に注目を集めています。 RailsにはTailwindが標準搭載されていませんが、この記事では新しいRuby on RailsプロジェクトにTailwind CSSを追加する方法を解説します。これにより、デザイン実装にかかる時間を大幅に節約できるでしょう。さらに、Tailwindのユーティリティクラス

  2. Rails5でのAngularの使用

    あなたは前にその話を聞いたことがあります。分散型で完全に機能するバックエンドAPIと、通常のツールセットで作成されたフロントエンドで実行されているアプリケーションがすでにあります。 次に、Angularに移動します。または、AngularをRailsプロジェクトと統合する方法を探しているだけかもしれません。これは、この方法を好むためです。私たちはあなたを責めません。 このようなアプローチを使用すると、両方の世界を活用して、たとえばRailsとAngularのどちらの機能を使用してフォーマットするかを決定できます。 構築するもの 心配する必要はありません。このチュートリアルは、この目的のた