phoenix 開発におけるエラーハンドリングポリシー
例外はルールを決めておかないと「こっちのメソッドではtryで捕まえないといけないけど、こっちでは{:error, message}
で返ってきて、どっちに合わせればいいのかわからん」のような事があってつらつらつらららあららら。
ちゃんとプロジェクトでのルールを明文化して統一しよう
方針
- Controller 層でエラーを検出したら例外を生成してphoenixの例外ハンドラに喰わす
- Service層-ドメイン層では例外生成せずにEither
- OOMみたいな致命的なものは別途検知して再起動しつつOpsチームに通知を飛ばす
- find のように見つからない可能性があるものは Maybe(listなら空List[])
- プロジェクトの ErrorHandler が using Plug.ErrorHandler して 404,500 をレンダリング
メリット
- Controller はなにも考えずに例外を raise すれば良いし、Presentation側のエラーハンドリング処理が一箇所に限定されてわかりやすい。
- Service層は例外を一切生成しないルールで統一する。Eitherで受ければtupleで受ける手間も省けるし、
|>
で合成するのも簡単。
構成
Elixir/Phoenixに関係なくWebアプリ全般の話をするが、この辺の認識が違うと話が噛み合わない事があるので書いておく。
User情報を入力してSignUpする場合を例にとる。model一個に限定した例じゃないよ、という意味でgroupを入れておいた。
/project
- lib/
- project/
- model/ 概ねrepoと同じような構成になるが、インピーダンスミスマッチがあるので常に同じになるとは限らない
- users/
- user.ex
- users.ex
- user_id.ex
... その他ドメインモデル、valueクラス、ドメインサービスなど
- groups/
- group.ex
..
- repo/ DBスキーマに対応する
- users/
- user_repo.ex
- groups/
- group_repo.ex
- service/ アプリケーションサービス
- guest/ ここではroleで切っているが、別の切り方でもいい
- signup_service.ex
- user/ 機能が複雑になればもうちょっと深い階層が必要になる
- profile_service.ex ... modelをまたいだ処理ができる(user&groupとか)
- project_web/
- router/
- guest_router.ex
- user_router.ex
- controller/ ユースケースに対応するので、roleごとにディレクトリが別れている
- guest/
- signup_controller.ex
- user/
- home/
- menu_controller.ex
- profile_controller.ex
- setting_controller.ex
- groups/ ユーザーは複数のgroupに属することが出来る
- group_controller.ex
...
- view/json_view.ex SPAに於いて個別にviewを用意する必要はないので変更した
modelの下にあるクラスははどこにも依存せず、modelフォルダごとどこかに移動してもそのまま使えるような作りなっている1。User.idはUserId型で持つし、Groupへの関連はGroupクラスのインスタンスを参照できるなど、ビジネスドメインとして使いやすい形になっている。
repoはEcto.Repoを使った各エンティティテーブルのfieldをそのまま反映していて、やはりrepo外のディレクトリには依存しない。UserRepoはidをテーブル定義と同じlongやuuid(string)で持っている。
serviceはrepoから読み込んだ情報を元にmodelのインスタンスを作ったり、逆に渡されたmodel情報を元にRepoにinsertを依頼する。
典型的なシーケンスを下図に示す
解説
原則としてService層以降では例外生成(Mix.raise)を行わない
返却値で異常を表現し、例外は生成しないことで統一する。ポリシーがはっきりしてい一貫性あるソースは読みやすいし書きやすい。ポリシーを理解する必要はあるので、「Service層では例外生成しない」というルールを理解できないメンバーがいるとつらいかもしれない。いるか?
OOM(OutOfMemory)が出た場合はメモリ状態に異常をきたしている可能性があり、速やかに再起動してキャッシュ戦略やハードウェア要件を見直す必要がある。このような致命的な状態については別途検討する必要があり、本記事では扱わない2。
失敗する可能性のあるメソッドの戻りは Either
失敗する可能性のあるメソッドを複数呼ぶ場合、elixirの典型的にはこうなる
with {:ok, model} <- repo.get(id),
{:ok, result} <- service.exec(model),
{:ok, result2} <- service2.exec(result) do
{:ok, result2}
else
{:error, message} -> {:error, message}
end
関数型言語を触ったことがある人ならピンとくると思うが3、これはEitherである。であれば素直にEitherを使ったほうがこんな面倒くさい受け取り方をする必要もなく、単純に|>
で合成できるようになる。elixirの標準にはEitherはないが、Algaeライブラリで提供されている。
例えばここでEither.mapメソッドは、Rightの中身に対してfunctionを実行し結果を再度Eitherに包み直し、Leftならそのまま返しているので「前の処理が成功した時のみ次の処理を実行し、またEitherに戻す」という事ができるので、Eitherを返す関数を合成してもネストしないように書くことができる。
{:ok, result}|{:error, message}
というtupleのorで表現した場合、withを使って「成功時のみ次の処理を実行する」部分だけは実現できる。が、呼び出すメソッドが全て綺麗に設計されていないとwithのネストやtupleのネストが発生して、見るも無残なソースコードとなってしまう。見るだけで具合が悪くなってくるのでやめよう。
Eitherを使った場合はこうなる
defmodule CTest do
alias Algae.Either.Right
alias Algae.Either.Left
use Witchcraft.Functor
def main(args \\ []) do
ok(2)
|> map(& twice(&1))
|> map(& twice(&1))
ng(2)
|> map(& twice(&1))
|> map(& twice(&1))
end
defp ok(x), do: Right.new(x)
defp ng(x), do: Left.new(x)
defp twice(x), do: x*2
end
Rightの場合のみ次の処理が行われ、Leftの場合はなにもしない。各行で出力すると下記のようになる
%Algae.Either.Right{right: 2}
%Algae.Either.Right{right: 4}
%Algae.Either.Right{right: 8}
%Algae.Either.Left{left: 2}
%Algae.Either.Left{left: 2}
%Algae.Either.Left{left: 2}
errorになる可能性のあるサービスを複数回呼び出すとき、flat_mapしたいので前回の更新ではflat_map(%Right{})
とflat_map(%Left{})
を自作してたんだけど、Witchcraft.Chain
を使えば良いみたい。
use Witchcraft.Chain
前はLeftのときも中身を取り出してたんだけどそれではだめで、LeftのときはLeftのまま継続してくれないとずっとEither型を保持することができない。chainの実装は正しい。これでEitherを返すfunctionを呼ぶときはflat_map,普通のfunctionを呼ぶときはmapを呼ぶことでシンプルに記述できる。
ok(2)
|> chain(& twice_throwable(&1))
|> chain(& ng(&1))
|> IO.inspect
Algaeを使うにあたってローカルにalgaeを落とす必要があったのでmix.exsのdepsを下記に示す。
defp deps do
[
{:type_class, path: "../type_class", override: true},
{:algae, path: "../algae", override: true},
]
end
詳細はElixir mix.exsでローカルにあるプロジェクトを参照するを参照してほしい。
実際に動かせるソースはgithubに置いた。Elixir1.9でコンパイルするのにalgaeとtype_classもcloneする必要があることに注意してほしい。
https://github.com/dobashi/ctest
プロジェクト名が適当でないので変更するかも
find のように見つからない可能性があるものは Maybe
nilおよびnilチェック撲滅のために、nilになる可能性があるものはMaybeでラップする。ラップされていないものについては絶対にnilにはならないという前提でコードを書く。これで不要なnilチェックがなくなり可読性があがる。「nilになるかどうかわからないものだらけだから、とりあえずnilチェック」みたいな書き方は絶対にしない、何故なら我々は脳があるので。Maybeも上記Eitherと同じくAlgaeにある
TODO Maybeのサンプルコード
Controller 層でエラーを検出したら例外を生成
Service層から返ってきた結果が異常値(Either.Left)であったとしても、それをどのように表示するかはControllerに委ねられている。たとえば外部ASPにアクセスするようなServiceロジックがあり、そのASPサイトがダウンしている時、Serivce層は当然「接続できないエラー(Either.Left)」で返してくるが、Controller側ではそれを見て動作を選択したい。
- ユーザーにシステムエラー通知するだけ(詳細は後述)
- 代替サービスに接続要求を投げる
- エラーがでた旨のみ表示して継続操作を促す
例えば郵便番号からの住所検索APIに接続できないなど、無視しても動作に影響がない(ユーザーに都道府県から入れてもらえばいいだけ)ケースがあるので、Service層のエラーが本システムの想定外のエラーであるとは限らない。例外生成を行うのは想定外の部分だけにとどめて見通しを良くする。Service層での異常ではMix.raiseすることなく、常にEitherで返すようにする(Algae/Eitherが使えなければ{:ok|:error})。
プロジェクトの ErrorHandler が using Plug.ErrorHandler して 404,500 をレンダリング
システム上起きないはずの異常を検知して、ユーザーには「エラーが発生しました(500 Internal Service Error)」と通知するケースでは、Controllerは単純にMix raiseするだけでよい。Phoenixで使っているのPlug.ErrorHandlerを実装し、そこで500ページのレンダリングを行い、開発者側に通知する。単にErrorログを出力して、これをOpsチームが検出して調査依頼を起票するのか、メールを送信するのかといった処理はプロジェクトによる。メール送信がさらに失敗する可能性もあり、それをさらにエラーハンドリングすると込み入ってくるので通常はErrorログを出力するだけになる。
参考: https://hexdocs.pm/plug/Plug.ErrorHandler.html
筆者が使ってるのは下記のようなview一つだけだ。jsonにしたときに頭に{"data":
とつけなければいけないプロジェクトだったのでこんなことになっているが、本来は1メソッドで良い。
参考: Poison->Jason変更 https://github.com/phoenixframework/phoenix/issues/2693
defmodule Project.JsonView do
alias Project.JsonView
use Project, :view
def render("index.json", %{json: x}) do
%{data: render_many(x, JsonView, "x.json")}
end
def render("show.json", %{json: x}) do
%{data: render_one(x, JsonView, "x.json")}
end
def render("x.json", %{json: x}) do
Project.MapConverter.without_metadata(x)
end
end
いちいち全クラスに書くのは面倒だからMapConverterというのを作ってmetadataを取り除くようにした。もちろん再帰して子メンバー含めて全部取り除くし、Listなどが来てもOKである。HEXにLxとして登録してあるので良かったら使ってみてほしい。Documentはそのうち書く。
defmodule Project.MapConverter do
def remove_metadata(target) do
is_struct = fn s -> is_map(s) && Map.has_key?(s, :__struct__) end
value = fn v -> if(is_map(v) || is_list(v), do: without_metadata(v), else: v) end
remove = fn x, key ->
{_, result} = Map.pop(x, key)
result
end
map = fn m -> if(is_struct.(m), do: Map.from_struct(m), else: m) |> remove.(:__meta__) end
case target do
nil -> nil
%DateTime{} = x -> DateTime.to_iso8601(x)
x when is_list(x) -> Enum.map(x, &without_metadata(&1))
x when is_map(x) -> for {k, v} <- map.(x), into: %{}, do: {k, value.(v)}
x -> x
end
end
end
こいつにはキーがstringなmapを再帰的にatomにしたり、mapからhttpパラメータのkey1=value1&key2=value2&...
の変換メソッドなどもあるのでそのうちHexにでも登録しようかな。
追記:登録した https://hex.pm/packages/lx
まとめ
- PhoenixってDBからフロントまで一気に面倒みようとしててmicroserviceな時代の流れに逆らってますね。外部APIにリクエストするだけのmix test書いてもDBに接続しようとしてうざい。というかフロント系とかRoRに影響を受けたあたりのカルチャーってなんでこんな稚拙なのばっかりなんですかねえ・・・
- 手軽にActor ModelをやりたいならElixirもいいかもしれない。Scala/Akkaでちゃんとやっても4Actor Modelで送ったメッセージは型情報が消失してしままい、型システムの恩恵が受けられないからだ。2019年時点でよく知られたベストプラクティスを考慮すると、なにか特別なメリットでもない限り業務で動的型付け言語を選択するのはアーキテクトの才覚を疑う5。JavaScriptですらいちいちコンパイルが必要なTypeScriptで書くようになったのに。
- 自分でQiitaに記事を書いておいてなんですが、調べ物しててQiitaが引っかかるとつらい気持ちになることが多いのでつらいですね。人生はつらい。
-
例えばmodelをライブラリとしてmix.exsのdepsに登録するような形にできる ↩
-
例えばJava VMなら起動時のオプションで「OOM検知で任意のコマンドを投げる」事ができるので「即座にメモリダンプをlogに出力 && Webサービス再起動 && 通知メール送信」という設定ができる。elixirはdump吐いて死ぬと思うので自分でprocess監視するスクリプト書けばいけるかも。同じサーバー上に複数のmix processがある場合、起動時のpidをとっといて管理するとか、dockerコンテナに入れて1コンテナあたりmix processが一つしかいない事を担保するとか、考慮することは多少あるが。 ↩
-
なに?Elixirも関数型言語?確かに。関数合成はできるし、状態変化を認めないからそう分類される。ただMonad使う文化が浸透してないので実際の現場だと他の関数型言語とかなり乖離がある気がする。あとElixirは最もOOPを体現した言語だと言う意見もある(メッセージング的な意味で) https://blog.noredink.com/post/142689001488/the-most-object-oriented-language ↩
-
Actor Modelの実装自体は頑張ればどんな言語でもできる。実際に筆者は1990年代にN系のシステム6でc++での実装を見た。Actor Modelの論文は1973年。https://dl.acm.org/citation.cfm?id=1624804 ↩
-
もちろん将来別のテクニックが発明されて動的型付け言語が優勢になる時代が来るかもしれない。それを見越した先見の明のある人物なのか?なぜそれを選んだか聞いてみればよい(大概ロクな理由ではない)。 ↩
-
(完全に余談だが)NはNTTではないし、IT系だとNTT Dataを'データ'と略すことはあってもNTTをNとはあまり言わない気がする。会社名を出せない場所でイニシャルトークしてる時にたまに使う程度で、NやFはイニシャルトークじゃない時でも通じる略語になってる。 ↩