ジンジャー研究室

長めのつぶやき。難しいことは書きません。

Elm で不正な JSON に厳しすぎる問題についてのメモ

長いだけで中身は単なる雑なメモなので、結論や主張はないです。

起こりうる問題

Elm で、サーバーがおかしな JSON を返してきた時にエラーが発生してアプリが止まることがある。一般的にはシステム境界でエラーが分かるのは嬉しいのだが、不寛容すぎると問題を起こしうる。 これは JavaScript とかだと起こらない問題で、 Elm とかだと起こりうる。例えば、{ name: string } みたいな API だとして {} が来た時に 「nameがない!」とエラーになる可能性がある。これを デコードエラー と呼ぶ。それが本番で運用した時に問題にならないか、という話。確率は低そうだが考えておいて損はなさそう。

Elm 以外も含めて言語別に見ると、

  • JS の場合、プロパティが null や undefined でも構わず処理を続け、運悪く当たるとぬるぽになる。型が違った場合は適当に処理される危険性がある。そのプロパティが運よく使われなかった場合は無事に処理が回る。
  • TypeScript の場合も、JS と同じ。型が違う場合はコードに書いてある型が嘘をつくので混乱するかもしれないが。
  • Elm の場合、JSON をデコードした時に間違っていればエラーになる。無いはずのプロパティがあっても問題にはならないが、その逆や型が違う場合にはエラーになる。

もちろんこれは API 側の問題なので、開発中は早期に問題が発見できて助かる。実際に、React から Elm に乗り換えた NoRedInk では、サーバー側(Ruby)のバグがクライアント(Web UI)側で見つかって便利みたいなことを報告している。

Fun Fact: I have heard a bunch of stories of folks finding bugs in their server code as they switched from JS to Elm. The decoders people write end up working as a validation phase, catching weird stuff in JSON values. So when NoRedInk switched from React to Elm, it revealed a couple bugs in their Ruby code!

Fun とか言ってるので、多分 NoRedInk では問題になっていない。実際 NoRedInk 的には何十万行の Elm コードがプロダクションで動いていて今までにランタイムエラーが1件とのことなので、少なくともランタイムエラーにはなっていない。ちなみに Elm では Debug.todo(旧 Debug.crash)だとランタイムエラーが発生して、画面は表示されてはいるがアプリケーションの動作としては完全にストップする。一方、それ以外の場合は、どこかしらでエラー処理されているか無視されているということになる。

想定ケース

具体的にどういう問題が起こるのか、それとも大して問題にならないのか。

大まかな前提としては、(A)初期化時に GET して (B)各ページの表示のためにまた GET して (C)それぞれのページで操作中に POST その他が発生する、というよくありそうな SPA とする。Debug.todo しない場合エラーを画面に表示する処理は書いてあるものとする。

どこで 何が起きると どうなる
A-1 初期化 Debug.todo 真っ白かガワが見えた状態でストップ
A-2 初期化 デコードエラー 真っ白かガワが見えた状態でエラー表示、その後の操作は受け付けるがメイン画面が出ないのでメインの処理はほとんど何もできない
B-1 ページ表示 Debug.todo ページ遷移前または遷移後にガワが見えた状態でストップ
B-2 ページ表示 デコードエラー ページ遷移前または遷移後にガワが見えた状態でエラー表示、その後で他のページには行ける
C-1 ページ操作 Debug.todo 処理後にストップ
C-2 ページ操作 デコードエラー 処理後にエラー表示、その後で別の操作はできる

また、上のそれぞれについて 将来的に他の依存しているサービスの API が勝手に変わる ことも考えうる。普通に運用していたら、いつの間にか死んでいたというパターン。というか、意外と世の中的にはそのパターンが多いのかもしれない。考慮に入れておこう。

いずれにしても、動作を完全にストップ Debug.todo は罪深い。しかし Debug.todo は書かなければ良いので、回避するのは容易そう だ。というわけで、とりあえずそれ以外にフォーカスする。

A-2, B-2, C-2 共になんらかの処理が不可能になってしまうのが問題のように思える。しかし、間違った処理をサーバー側に伝播させるよりはマシとも言える。10 + 010 だが、 "10" + 0"100" になり、いつの間にか 10 万円のつもりが 100 万円払っていたような事故は防げる。反論としては「どこかでランタイムエラーになるんじゃね?」or「極端な値だとバリデーションで弾かれる」or「テストしてるから大丈夫」はありそうだが、確率の話になってきそう。あるいは JS でなく "10" + 0 をランタイムエラーにしてくれる言語があったとして、それで良いのかもしれないが。

とりあえず、ここでは「全くチェックしなくても確率的に大丈夫そう」は話が発散するので除外しておこう。テストはした方が良い、それは間違いない。しかし我々は型の恩恵をフルに受ける言語を選んだのだから、別のところでメリットは出ているはず。その前提で、ここからは問題解決の方法について検討したい。

チェックの粒度とタイミング

「どこかがおかしくても一部の処理は生き残っていて欲しい」が要望だとすると、言い換えると問題は次の2つになる。

  • 一度にチェックする粒度が大きすぎる
  • チェックするタイミングが早すぎる

あるいは「なるべく小さい粒度で、ギリギリまでチェックを遅らせて欲しい」ということになるのかもしれない。

何か例が欲しい。次の User 型をデコードしよう。

decodeUser : Decoder User
decodeUser =
  map3 User
    (field "id" int)
    (field "name" string)
    (field "age" int)

今、 { id: 1, age: 20 } だとすると "name" が足りないのでエラーになる。が、"id" を使った処理はできるはず。 ここまで書いて気づいたけど、「未然に防ぐには?」なのか「起きてしまった時にどう対処する?」という2種類の話がある気がする。未然に User の型を捻じ曲げるのは不可能と思われる。例えば id name age 全てを Maybe にするとか、考えたくもない。しかし事後で良いなら name だけを Maybe にするのは可能そうだ。それで改めて何かの処理をしようと思ったら「残念、値がありませんでした!」と言える。それは十分現実的なので、なるべく早めに察知できる仕組みを整備したい(それがデコードエラーであることがすぐに分かるのはメリットだ)。

あとは、デコードを遅らせる手として Json.Decode.value を使うことも検討したい。要するに GET した時点では任意の JSON として取得して、使う段になって改めてデコードする。これならば、例えば id が必要となった時に id だけをデコードして使うことができる。

decodeUserId : Decoder Int
decodeUserId =
  field "id" int

とは言え、全てのデータに関してこれをやるのは正直だるすぎて死ぬ(ひょっとして Haskell だったら遅延評価だから素でこういうことができる?)ので、使いどころを絞りたい。例えば、独立した機能のデータを一度に取得した場合に

type alias Features =
  { feature1 : Value
  , feature2 : Value
  , feature3 : Value
  }

とでもしておいて、それぞれの機能を使う段になって初めてデコードする。これは業務でも似たようなことをやったことある、けどデコードエラーが十分想定しうる場合だったような気はする。全く想定外だったらこういう構造になるか怪しい。

今のところ考えられる対策

まとまらないけど、とりあえずこの辺で。

  • 少なくとも Debug.todo は避けよう
  • エラーハンドリングしてサーバーに通知する
  • 事後で良ければ Maybe などを使ってエラーを遅延できる
  • 場合によっては Json.Decode.value を使ってデコードのタイミングを遅らせることはできそう