ElixirのコレクションをEnumで処理する際のエラーハンドリングを呼び出し側で行なう

はじめに

Elixirにはコレクションを列挙して処理するためのモジュールとして Enum モジュールというのがありますが、列挙中にエラーが発生して、実行をやめたい場合のエラーハンドリングについてやり方を調べる機会があったのでそのメモを残しておきます。

Enun.reduce_while

結論から言うとEnum.reduce_whileを用いると可能になります。
Enum.reduce_whileは以下のインターフェースとなっています。

reduce_while(enumerable, acc, fun)

第一引数にコレクション、第二引数にアキュムレータの初期値、第三引数にコレクションに対して実行する関数を受け取ります。
また、第三引数の関数は以下の2つの形式のタプルどちらかを返すことが期待されます。

  • {:cont, acc}
  • {:halt, acc}

Enum.reduce_whileでは、コレクションをすべて消費するか{:halt, acc}が呼び出されるまで実行を継続します。
アイディアとしてはこの{:halt, acc}accのところでエラー情報を返してやれば、Enum.reduce_whileを呼び出している側でエラーハンドリングを行なうことが可能となります。
実際に使ってみたほうがわかりやすいので、説明はこの辺にします。

使ってみる

環境

利用するElixirのバージョンは以下のとおりです。

$ elixir -v
Erlang/OTP 23 [erts-11.0.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [hipe]

Elixir 1.10.4 (compiled with Erlang/OTP 22)

プロジェクトの作成

mixでプロジェクトを作成します。今回は動作を確認するためのテストも記述します。Enum内でエラーを意図的に起こすために関数の呼び出しをMock化します。モックライブラリとしてはmockを利用します。

$ mix new enum_error_handling

mix.exsに依存を追加し、mockをインストールします。

  defp deps do
    [
      {:mock, "~> 0.3.0", only: :test}
    ]
  end

コードを記述する

Enum.reduce_whileを使ったコードをいかに記述します。

defmodule EnumErrorHandling do

  alias EnumErrorHandling.DoSomething

  require Logger

  def hello_reduce_while(some_collection) do
    Enum.reduce_while(some_collection, {:ok, []}, fn value, {:ok, acc} ->
      case DoSomething.do_something(value) do
        {:ok, new_value} -> {:cont, {:ok, [new_value | acc]}}
        :error = err -> {:halt, err}
      end
    end)
    |> case do
      {:ok, new_collection} -> {:ok, Enum.reverse(new_collection) }
      :error ->
        Logger.error("something hannpend")
        :error
    end
  end
end

defmodule EnumErrorHandling.DoSomething do

  def do_something(value) do
    # エラーが発生する可能性がある何かの処理をする
    value
  end
end

関数hello_reduce_whileではリストを受け取り、リストのそれぞれの値に対して、do_somethingを実行し、その結果を新たなリストとして返す関数です。
EnumErrorHandling.DoSomething.do_something/1はエラーが起こる可能性がある処理で、エラーが起こった場合はEnum.reduce_whileの実行結果として{:halt, err}errが返され呼び出し側でエラーハンドリングを行なうことが可能となります。上記のコードではEnumの処理中にエラーが発生した場合呼び出し側の方でエラーログを出力し:errorを返すようなハンドリングを行っています。
それではこの関数を実行するテストを書いてみます。

コードを実行してみる

まずは正常系のテストを記述します。

defmodule EnumErrorHandlingTest do
  use ExUnit.Case, async: false
  import Mock

  test "success" do
    with_mock EnumErrorHandling.DoSomething,
      do_something: fn value ->
        {:ok, value <> ": did something"}
      end do
      some_list = ["value1", "value2", "value3"]

      assert {:ok, ["value1: did something", "value2: did something", "value3: did something"]} ==
               EnumErrorHandling.hello_reduce_while(some_list)
    end
  end
end

mockでEnumErrorHandling.DoSomething.do_something/1の呼び出しの処理をモックしています。
処理は単純で受け取った文字列に対して、": did_something"の文字列を追記しています。
このテストは実行すると成功します。

それでは次に異常系のテストを記述します。
期待するのははエラー時にエラーログを出力し、:errorのアトムが返されることです。
テストは以下のようになります。

  test "failed" do
    with_mock EnumErrorHandling.DoSomething,
      do_something: fn value ->
        case value do
          "value2" ->
            :error
          _ ->
            {:ok, value <> ": did something"}
        end
      end do
      some_list = ["value1", "value2", "value3"]

      assert EnumErrorHandling.hello_reduce_while(some_list) == :error
    end
  end

こちらのテストも成功しログは以下のようになります。

$ mix test
.
12:57:11.745 [error] something hannpend
.

Finished in 0.08 seconds
2 tests, 0 failures

Randomized with seed 657765

"success"のテストと同様に成功し、12:57:11.745 [error] something hannpendのエラーログが出力されているのがわかります。 いい感じにハンドリングできてますね。

参考資料