内部エラーと外部エラー、それからGoのerrorがスタックトレースを含まない理由の考察

2024/03/23に公開
3

TL;DR

  • Goのerrorは外部エラーを表現するもので、利用者(ライブラリならアプリケーション開発者、アプリケーションならユーザー)に入力値や環境の不備を伝える目的で存在している
  • 一方で、開発中のソフトウェアが思っていたのと違う挙動を示した場合、その調査にはソフトウェアの特性によって異なるツールを使い分ける必要があり、一筋縄では行かない
  • 両者を分けて考えることで、よりよいエラーハンドリングへの議論が初めて進められると考える

背景

Goのerrorがスタックトレースを含まないことは度々Goの欠点として挙げられます。たしかに、一度作ってどこかにデプロイしたソフトウェアがバグっており、スタックトレースがないせいでその原因調査に手間取ったことは筆者も一度のみならず体験しています。

一方で、(しばしばベテランのプログラマから)エラーはノイズを含むべきではないと主張されることも少なくありません。

2つの異なる主張に対して筆者は、「情報が多い分には後からフィルタリングするだけでいいから、とにかく出せる情報を出すべきで、Goは間違っている」と考えていました。

しかしよくよく考えてみると、この2つの「エラー」は異なるものを指しているとすれば、両方をうまく説明できることに気が付きました。それが「内部エラーと外部エラー」という表現です。

2つの異なるエラー

内部エラーと外部エラーの違いは、HTTPステータスコードで考えるとわかりやすいかもしれません。400番台はユーザーに伝えるべきエラーで、500番台はユーザー側には問題のない内部のバグです。

ライブラリからアプリケーションに返されるエラーには、ライブラリの実装の詳細は含まれていてほしくないはずです。アプリケーションからユーザーに返すエラーなら尚更です。まさかフォームに使えない文字を入れただけで画面にスタックトレースは出さないでしょう。

一方で自分が作ったプログラムがバグっていた場合、スタックトレースを始めとしたなるべく多くの情報が必要になります。

この「なるべく多くの」という点が厄介で、実はスタックトレースですら十分でないケースが存在します。筆者が実際に遭遇したケースとして、字句解析器で考えてみましょう。なぜか間違ったトークンが送出されてくる場合、必要な情報としては過去のスキャン位置などが含まれるでしょう。実装にもよりますが、何回も呼び出すことでスキャン位置を進めるタイプの字句解析期の場合、この情報はスタックトレース上には乗っていないことが考えられます。すると、別途適切なトレースの実装が必要になります。

実際、go/parserはこのように trace 関数で明示的にトレースを出力する処理を持っています。

https://github.com/golang/go/blob/c2c4a32f9e57ac9f7102deeba8273bcd2b205d3c/src/go/parser/parser.go#L479-L482

字句解析器は純粋にロジックの問題でしたが、他にも、詳細なトレースを取ることがパフォーマンス上の負荷となり難しいケースも多いでしょう。

最近流行りつつあるOpenTelemetryなどは、スタックトレースでは不可能なサーバー(プロセス)をまたいだトレースを収集する方法を提供するものです。

したがって、内部エラーの調査にはソフトウェアの性質を踏まえた適切な技術選定が必要である、と言えそうです。

Goと内部エラー

Goのerrorは内部エラーを調べる目的のものではないですが、errorとは別の形で内部エラーを調査する方法はかなり潤沢に提供されていますし、Go teamが最近力を入れているところです。

公式ブログのMore powerful Go execution tracesという記事がまさにそのことについて言及したものです。

More powerful Go execution traces に掲載されたスクリーンショット
スタックトレースだけでなく、いろんな情報が盛りだくさん

このスクショのような機能だけでなく、Java発祥のフライトレコーダー機能(何かあったときに備えてデータを蓄積しておく機能。ドライブレコーダーみたいなもの)が試験的に実装されたり、どんどん充実していく気配があります。

これだけ豊富な情報を取得し蓄積するのは便利な一方でシステムの負荷にもなるので、最近その負荷が大きく改善されたのは嬉しいニュースですね。これで気兼ねなくトレースを取れるというものです。

もちろんこの情報だけですべての内部エラーを簡単に特定できるとは言いませんが、とにかく「外部エラーとは別に実装を考える必要がある」ことは伝わるのではないでしょうか。

冒頭に述べた「情報が多い分には後からフィルタリングするだけでいいから、とにかく出せる情報を出すべきだ」という主張には問題があることもお分かりいただけたかと思います。

というのを踏まえて

以上の考察を踏まえ筆者はまず、外部エラーの報告に使うエラー値の内容は厳選するよう見直しました。ある一定の境界の外にいる利用者に伝えるべきエラーは長ったらしいものではいけませんし、「正しいエラーを報告できているか」もテスト観点にしっかり加える必要があります。

一方で、そこまで大掛かりではないツールの内部エラーの調査に使うものとしては、error型で頑張るのをやめ、とりあえず雑にデバッグログ的なものを仕込んでおこうと考えています。雑にとはいえ、これも収集の方法をちゃんと考える必要があり、大変なのですが......。

いずれ大規模なシステムを組むことになったら、そのときは前述したような公式のトレースツールが役に立ったりするかもしれません。私はまだ実感できる段階にはないです。

またクライアントサイドのGo(ゲームとかゲームとか)関連は頭痛の種になりそうです。サーバー用途で設計されたトレースツールはおそらく適していないので、いかに内部動作をトレースするか......先行研究を踏まえつつ考えていく必要がありそうです。

まとめ

というわけでまとめです。

別にこれが正解!というものはないのですが、とりあえず内部エラーと外部エラーは別物であると認識することではじめて、正しいエラーハンドリングへの道が見えてきそうでした。

その上でどうやったら必要な情報を取れるかは非常に難しい問題です。みんなで大いに悩んでいきましょう......!!!

Discussion

methanemethane

Goのエラーにスタックトレースが標準で入っていないのは、初期に入ってなくて、後から入れるにはパフォーマンスの影響があるからです。これは諸説とかないただの事実です。

Goのエラー処理を改善する実験プロジェクトxerrorsがGo本体のerrorsにマージされた時、errors.New()はスタックトレースを取得していました。しかしパフォーマンスのオーバーヘッドが存在するので、今までのerrors.New()のパフォーマンスに依存していたコードの速度が低下します。それが許容できないケースもあるということで、スタックトレースは削除されました。

https://github.com/golang/go/issues/30468

なので、Goのエラーに標準でスタックトレースが含まれないことに対して、何かエラー処理に対する哲学のようなものを見出そうとするべきではありません。見つけられるのは、Goが後方互換性とパフォーマンスを大事にしているということだけです。
エラーにスタックトレースをつけることによる開発生産性はGoの開発者も合意していたのですから、パフォーマンスクリティカルな部分以外では便利に使うべきです。

eihigheihigh

貴重な知見ありがとうございます。確かにこの辺りの意見を顧みべきでした。

続編としてもう一本記事を出そうと思うので、若さゆえの過ちだと思って大目に見てご容赦いただけると、助かります🙏