jiechen257

jiechen257

twitter
github

Goのエラーハンドリングの優雅化

現在の Go バージョン 1.17.5

Go のエラー処理設計#

Go のエラー処理については、常に議論が絶えません。実際、Go 自体にも多くの議論があります 😂。ここでは評価を行わず、単に主流の言語と比較し、Go の特徴をまとめます。

歴史のある言語である C のエラー処理は非常に混乱しています。典型的には、C は戻り値を通じて実行が成功したかどうかを示し、失敗の具体的な理由は追加のグローバル変数を通じて伝えます。この設計の致命的な欠陥は、エラーと戻り結果が混同されることです。例えば、関数 int atoi(const char *str) は、変換エラーが発生した場合に何を返すべきでしょうか?0?-1?実際、どちらも合理的ではありません。なぜなら、どんな数字も正常な結果を表す可能性があるからです。

そのため、一部の開発者はポインタを利用して結果を渡し、戻り値はエラーが発生したかどうかのみを示すことを選択しました。これは確かに上記の問題を解決しましたが、「パラメータ」の意味が混乱する原因にもなりました。経験のない人は、何の入参出参か分からず困惑するでしょう。

近代の言語は通常、try-catchの思想を採用しています。例えば、Java や Python です。このモデルはエラーと戻り値、パラメータを分離し、構造化された処理の可能性を提供します。オブジェクト指向の思想を通じて、開発者はエラークラスやサブクラスをカスタマイズでき、これらは他のエラーをラップすることができ、エラーのコンテキストが失われないようにします。


Go は他とは異なります。Go は多戻り値の特性を利用してエラーを分離しているため、多くの Go の関数の最後の戻り値はエラーが発生したかどうかを示すerrorです。C と比較するとこれは進歩ですが、柔軟なキャッチメカニズムを提供していないため、意見が分かれるところです。

私の個人的な見解は:A 言語で B 言語の機能を再現しようとするのではなく、言語自体の特徴に基づいて、マクロな理念の指導の下で、特定のエラー処理のソリューションを実現すべきです。 後の文ではこの見解に基づいてさらに探求します。

基本理念#

文法は異なりますが、言語に依存しないいくつかの思想は非常に重要であり、私たちはできるだけ従うべきです。

まず、エラー情報は少なくとも 2 つの役割を持つべきです:

  1. プログラムに見せる。エラーの種類に応じて処理の分岐に入ることができます。
  2. 人に見せる。何が起こったのかを教えてくれます。

エラーを繰り返し処理しない#

これは非常に簡単に陥る罠だと思います。以下のコードを考えてみてください:

func foo() (Result, error) {
 result, err := repository.Find(id)
 if err != nil {
   log.Errof(err)
   return Result{}, err
 }
 return result, nil
}

ここではエラーを印刷(処理に相当)し、その後それを返しています。想像してみてください、foo()の呼び出し元は再び印刷し、再び返します。最終的に 1 つのエラーが大量の情報を印刷することになります。

エラーは呼び出しスタックを含むべき#

呼び出しスタックはデバッグの基本的なニーズです。単にエラーを一層一層返すだけでは、最上位の関数は最下層のエラーを受け取ることになります。例えば、Go で Web バックエンドを書いていて、支払いリクエストを処理中に io エラーが発生したとしましょう。これはデバッグには全く役立ちません —— どの段階でエラーが発生したのか特定できません。

もう一つの良くない解決策は、各レイヤーが自分のエラーだけを返すことです。例えば:

func pay(){
  if err := checkOrder(); err!=nil {
    return errors.New("支払い異常")
  }
  return nil
}

func checkOrder() error {
  if err := calcMoney(); err!=nil {
    return errors.New("金額計算異常")
  }
  return nil
}

func calcMoney() error {
  if err := querySql(); err!=nil {
    return errors.New("データベースクエリ異常")
  }
  return nil
}

この場合は前のものとは逆です:最上位の関数は最近のエラーしか得られず、引き起こした原因については全く知識がありません。明らかにこれは私たちが望むものではありません。

エラーは構造化されるべき#

賢い同僚が呼び出しスタックの喪失問題を解決するためにコードを改造しました:

func checkOrder() error {
  if err := calcMoney(); err!=nil {
    return fmt.Errorf("金額計算異常:%s", err)
  }
  return nil
}

確かに、修正後の最上位関数が得る例外は次のようになります:

支払い異常:金額計算異常:データベースクエリ異常

これで基本的に呼び出しスタックが見えてきましたが、人間に限ります。このエラーはデータベースの異常に属するのでしょうか?コンピュータには分かりません。自然言語処理で解決できます、あなたが狂っていない限り。

したがって、構造化されたエラーが必要です —— エラーには包含関係があり、サブエラーは親エラーの一種であり、最上位の関数は異常の種類を簡単に判断できます。

エラーにはコンテキストが必要#

これは非常に理解しやすい点です。私たちはエラーログに関連するデータ、例えばユーザー ID、注文 ID などを含めたいと思っています。

注意すべきは、この原則は「繰り返し処理しない」と結びつける必要があるということです。さもなければ、次のような天書のログが得られます:

支払い異常 uid=123, orderId=456, reqId=328952104:金額計算異常 uid=123, orderId=456, reqId=328952104:データベースクエリ異常 uid=123, orderId=456, reqId=328952104

実践#

エラー連鎖#

かつて、Go で呼び出しスタックと構造化を保持することは非常に面倒でした。そのため、errorsオープンソースライブラリが広く使用されました。しかし、Go 1.13 はある程度エラー処理を強化し、Go 2 もさらに改善する計画です。したがって、このライブラリはメンテナンス状態に入っています。

現在、呼び出しスタックは自分で構築する必要がなく、fmt.Errorf("... %w", ..., err)を使用することでエラーをラップし、層を重ねてエラー連鎖を形成できます。それに応じて、errors.Is()またはerrors.As()を使用して、あるエラー(連鎖)が別のエラーであるか(含まれているか)を判断できます。前者は厳密な等価性を要求し、後者は型が一致するだけで済みます。知らず知らずのうちに、「構造化」も基本的に実現されました。

待って!なぜ「基本」と言うのでしょうか?標準ライブラリはエラータイプを判断する方法を提供していますが、エラータイプとは何でしょうか?Java で具体的な例外をスローするのに対し、Go では基本的に底層のエラーインターフェースerrorを返すだけで、具体的な構造体はありません。それではどうやって判断するのでしょうか?またerr.msgの時代に戻るのでしょうか?もちろん違います。エラーをラップする際、fmtを使用する以外にも、自分で構造体を定義できます。実際、fmt.Errorf()が返すのはwrapErrorであり、明確なエラータイプを必要としないシナリオのための便利な関数です。

カスタム構造体#

カスタムエラー構造体はエラータイプの識別を助けるだけでなく、コンテキストの問題も解決します。単純な文字列で十分ですが、コンテキスト自体もプログラムで識別できるようにするためのより良い方法は、構造体のフィールドとして持つことです:

type orderError struct {
  orderId int
  msg string
  err error
}

func (e *orderError) Error() string {
    return e.msg
}

func (e *orderError) Unwrap() error {
    return e.err
}

この構造体はエラーインターフェースを実装するだけでなく、追加でUnwrap()メソッドを持っているため、他の例外をラップすることができ、呼び出しスタックが失われないようにします。

これで次のように返すことができます:

func checkOrder() error {
  if err := calcMoney(); err!=nil {
    return orderError{123, "金額計算異常", err}
  }
  return nil
}

公開 OR 非公開#

自分の構造体を持つことで、それを公開する必要があるかどうかが問題になります。この問題については標準ライブラリが答えを示しています:一般的には公開する必要はありません。したがって、私たちが見るほとんどの関数はerrorを返し、xxxErrorを返すことはありません。オンラインの資料によると、これは実装の詳細を隠し、ライブラリの柔軟性を高め、アップグレード時に考慮すべき互換性の問題を減らすことを目的としています。

構造体を公開しないということは、外部がerrors.As()を通じて判断できないことを意味します。そのため、外部が本ライブラリのエラーであるかどうかを確認するための関数を公開する必要があります。

func IsOrderError(err error) bool {
  return errors.As(err, orderError)
}

エラーチェック地獄#

上記ではエラーを返す構造の要約を行い、最初に提起した基本理念に合致しています。実際のコードでは、エラーチェックがプロジェクトにあふれ、さらには各呼び出しが即座に中断して返すためのifで包まれていることさえあります —— なぜなら Go には throw や raise のメカニズムがないからです。以下の不快な例を見てください:

func checkPersion(*p Persion) error {
  if err := checkAttr(p.name); err != nil{
    return err
  }
  if err := checkAttr(p.age); err != nil{
    return err
  }
  if err := checkAttr(p.country); err != nil{
    return err
  }
  if err := checkAttr(p.work); err != nil{
    return err
  }
  return nil
}

匿名関数を抽出する#

この方法は同じ関数を連続して呼び出す場合に適しています。

エラーチェックを匿名関数に抽出し、すでにエラーが存在する場合は実行せずに直接返します。

func checkPersion(*p Persion) error {
  var err error
  check := func(attr interface{}){
    if err != nil{
      return
    }
    err = checkAttr(attr)
  }

  check(p.name)
  check(p.age)
  check(p.country)
  check(p.work)
  // さらにチェック
  return err
}

panic を利用する#

⚠️ panic をエラーの代わりに使用するのは誤った習慣です。このテクニックを乱用しないでください。

// checkAttr()はもはやエラーを返さず、直接panicします
func checkAttr(attr interface{}) {
  if attr == nil{
    panic(checkErr{...})
  }
}

func checkPersion(*p Persion) (err error) {
  defer func() {
    if r := recover(); r != nil {
      // checkAttr()のpanicをエラーに変換して回復します
      err = r.(checkErr)
    }
  }()

  // 何かを行う
}

panic を使用してチェックを簡素化する鍵は、回復時に既知のエラーのみを処理し、未知の状況は panic をそのまま伝えることです。panic は原則として回復不可能な重大なエラー(例えば配列の越境)にのみ使用されるべきであり、すべてのケースで一律に回復すると、バグを隠蔽し、未知の結果を引き起こす可能性があります。

あるウェブサイトでは、以下のような書き方が推奨されていますが、非常にお勧めできません。自分が何をしているのか分かっている場合を除いて:

func checkPersion(*p Persion) (err error) {
  defer func() {
    if r := recover(); r != nil {
      var ok bool
      // ここで未知のエラーも一緒に捕まえています
      err, ok = r.(error)
      if !ok {
        err = fmt.Errorf("人をチェックするのに失敗しました: %v", r)
      }
    }
  }()
  // 何かを行う
}

多くのウェブサイトが panic の乱用を避けるように宣伝していますが、私は最初の例のように、既知の例外のみを捕まえてエラーチェックを簡素化するのであれば、乱用とは見なされないと思います —— この場合、panic はパッケージ外の呼び出し元に影響を与えません —— 元々panic するものは今も panic し、元々エラーを返すものは今もエラーを返すだけです。Go の公式ドキュメントeffective goもこの使用法を認めています:

回復パターンが整ったことで、do関数(およびそれが呼び出すもの)は、panicを呼び出すことで、どんな悪い状況からもクリーンに脱出できます。このアイデアを使って、複雑なソフトウェアのエラー処理を簡素化できます。

回復モードがあれば、いつでもpanicを呼び出して異常な状況から簡単に脱出でき、複雑なソフトウェアのエラー処理を簡素化するためにこの考え方を使用できます。

転載#

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。