[Golang] エラーハンドリング

2021-12-03 hit count image

Golangでエラーを生成したらハンドリングする方法について説明します。

概要

今回のブログポストではGolangでエラーをハンドリングする方法について説明します。このブログポストで紹介するコードは次のリンクで確認できます。

エラーハンドリング

プログラミングでエラーはいつでも起きる問題です。

  • バグ: ヒューマンエラー(プログラマーのミス)、プログラムの誤動作
  • 外部環境: マシンの問題(メモリ不足)、物理的な問題(電源遮断)

このエラーを処理する時、できる方法は2つあります。1つは実行中のプログラムを終了させることと、もう1つはエラーをハンドリング(Handling)してプログラムを持続させる方法です。

エラーリターン

Golangでは次のようにエラーをリターンして、コードを使う方でエラーをハンドリングできるようにしてます。

import "os"

file, err := os.Open(filename)
file, err := os.Create(filename)

これを確認するためmain.goファイルを生成して次のように修正します。

package main

import (
  "bufio"
  "fmt"
  "os"
)

func ReadFile(filename string) (string, error) {
  file, err := os.Open(filename)

  if err != nil {
    return "", err
  }

  defer file.Close()

  rd := bufio.NewReader(file)
  line, _ := rd.ReadString('\n')

  return line, nil
}

func WriteFile(filename string, data string) error {
  file, err := os.Create(filename)
  if err != nil {
    return err
  }

  defer file.Close()

  _, err = fmt.Fprintf(file, data)
  return err
}

const filename string = "data.txt"

func main() {
  line, err := ReadFile(filename)
  if err != nil {
    err = WriteFile(filename, "Hello, World!\n")
    if err != nil {
      fmt.Println("Failed to create file!", err)
      return
    }
    line, err = ReadFile(filename)
    if err != nil {
      fmt.Println("Failed to read file!", err)
      return
    }
  }
  fmt.Println("File contents: ", line)
}

これを実行すると次のような結果が表示されます。

# go run main.go
File contents:  Hello, World!

このように関数を生成して提供する時、エラーをリターンするようにして使う方でエラーをハンドリングするようにした方が良いです。

カスタムエラーリターン

Golangでは次のようにカスタムエラー(Custom error)を作ることができます。

fmt.Errorf(formatter string, ...interface {})

または、次のようにerrorsパッケージを使って生成することができます。

import "errors"

errors.New(text string) error

これを確認するため、次のようにmain.goファイルを修正します。

package main

import (
  "errors"
  "fmt"
)

func errorTest(i int) (int, error) {
  switch i {
  case 0:
    return i, fmt.Errorf("Error: %d", i)
  case 1:
    return i, errors.New("Error")
  default:
    return i, nil
  }
}

func main() {
  i, err := errorTest(0)
  if err != nil {
    fmt.Println(err)
  }

  i, err = errorTest(1)
  if err != nil {
    fmt.Println(err)
  }

  i, err = errorTest(2)
  if err != nil {
    fmt.Println(err)
  }
  fmt.Println(i)
}

これを実行すると次のような結果が表示されます。

# go run main.go
Error: 0
Error
2

普通は、エラーメッセージに変数の値を出力したい時、fmt.Errorfを使って、単純に文字列を出力したい時はerrors.Newを使います。

エラータイプ

エラーメッセージに色んな情報を追加して出力したい場合、エラータイプを使うことができます。

type error interface {
  Error() string
}

GolangではErrorメソッドを実装するオブジェクトをエラータイプとして使うことができます。例えば、次のようにPasswordErrorタイプを定義して、Errorメソッドを実装したら、エラータイプとして使うことができます。

type PasswordError struct {
  RequiredLen int
  Len         int
}

func (e PasswordError) Error() string {
  return "Password is too short."
}

これを確認するため、main.goファイルを次のように修正します。

package main

import (
  "fmt"
)

type PasswordError struct {
  RequiredLen int
  Len         int
}

func (e PasswordError) Error() string {
  return "Password is too short."
}

func RegisterAccount(name, password string) error {
  if len(password) < 8 {
    return PasswordError{RequiredLen: 8, Len: len(password)}
  }
  // register account logic
  // ...
  return nil
}

func main() {
  err := RegisterAccount("john", "12345")
  if err != nil {
    if errInfo, ok := err.(PasswordError); ok {
      fmt.Printf("%v (Required: %d Len: %d)\n", errInfo, errInfo.RequiredLen, errInfo.Len)
    }
  }
}

これを実行すると次のような結果が表示されます。

# go run main.go
Password is too short. (Required: 8 Len: 5)

このようにエラーに色んな情報を追加する時、エラータイプを使います。

エラーのラッピング

Golangではエラーをラッピング(Wrapping)して複数のエラーを追加して1つのエラーを作ることができます。エラーのラッピングには次のようにfmt.Errorfを使います。

fmt.Errorf("%w", err)

また、errorsのパッケージのIsAsを使って、ラッピングされたエラーかどうかを確認することができます。

errors.Is(err, StrError)
errors.As(err, &strError)

これを確認するため、main.goファイルを次のように修正します。

package main

import (
  "errors"
  "fmt"
)

type StrError struct {
  Msg string
}

func (e *StrError) Error() string {
  return fmt.Sprintf("Message: %s", e.Msg)
}

func msgError(msg string) error {
  return &StrError{Msg: msg}
}

func WrapError(msg string) error {
  err := msgError(msg)
  return fmt.Errorf("(Wrapping) %w", err)
}

func main() {
  err1 := msgError("Error")
  fmt.Println("[Normal Error]", err1)

  err2 := WrapError("Test Error Message")
  fmt.Println("[Wrapping Error]", err2)

  var strErr *StrError
  if errors.As(err2, &strErr) {
    fmt.Printf("[Failed] %s\n", strErr)
  }
}

これを実行すると次のような結果が表示されます。

# go run main.go
[Normal Error] Message: Error
[Wrapping Error] (Wrapping) Message: Test Error Message
[Failed] Message: Test Error Message

このようにGolangではエラーをラッピングして1つのエラーでハンドリングすることができます。

パニック

Golangではパニック(Panic)を使ってエラーと一緒にプログラムを終了されることができます。パニックはプログラムを早めに終了させて、プログラマーがエラーを認識して直せるようにします。

これを確認するためmain.goファイルを次のように修正します。

package main

import "fmt"

func divide(a, b int) {
  if b == 0 {
    panic("divide by zero")
  }
  fmt.Printf("%d / %d = %d\n", a, b, a/b)
}

func main() {
  divide(4, 2)
  divide(4, 0)
  divide(4, 3)
}

これを実行すると次のような結果が表示されます。

# go run main.go
4 / 2 = 2
panic: divide by zero

goroutine 1 [running]:
main.divide(0x4, 0x2)
        /study-golang/error-handling/5.panic/main.go:7 +0x10b
main.main()
        /study-golang/error-handling/5.panic/main.go:14 +0x31
exit status 2

2番目のdivide関数のコールでパニックが発生して、プログラムが終了されます。そのせいで、3番目のdivide関数はコールされないことが確認できます。

パニックは次のようにどのタイプでも渡すことができます。

func panic(v interface{})

従って、次のようにパニックを使うことができます。

panic(42)
panic("error")
panic(fmt.Errorf("error num: %d", 42))
panic(SomeType{SomeData})

パニックの伝播や回復

プログラムを開発するときには問題を早めに見つけて解決することが大事です。従って、パニックを使って問題が発生したら、プログラムを終了させて、早めに問題を見つけて解決するようにした方が良いです。

しかし、開発したプログラムを外部に公開してサービスする場合はプログラムを終了されないようにすることが大事になります。ちょっとだけ使ったのにプログラムが強制終了されたら、誰もそのプログラムは使わないことになると思います。

Golangはこのためパニックを回復させるrecoverを提供してます。

func recover() interface {}

recoverdeferと一緒に使われてパニックを回復することができます。そしたら、これを確認するたえmmain.goファイルを次のように修正します。

package main

import (
  "fmt"
)

func firstCall() {
  fmt.Println("(2) firstCall function start")
  defer func() {
    if r := recover(); r != nil {
      fmt.Println("Recovered in firstCall", r)
    }
  }()

  group()
  fmt.Println("(2) firstCall function end")
}

func group() {
  fmt.Println("(3) group function start")
  fmt.Printf("4/2 = %d\n", divide(4, 2))
  fmt.Printf("4/0 = %d\n", divide(4, 0))
  fmt.Println("(3) group function end")
}

func divide(a, b int) int {
  if b == 0 {
    panic("divide by zero")
  }
  return a / b
}

func main() {
  fmt.Println("(1) main function start")
  firstCall()
  fmt.Println("(1) main function end")
}

これを実行すると次のような結果が表示されます。

# go run main.go
(1) main function start
(2) firstCall function start
(3) group function start
4/2 = 2
Recovered in firstCall divide by zero
(1) main function end

パニックを回復するため、recoverを使った関数はfirstCallであることが分かります。パニックが発生する場所はdivide関数です。このようにパニックはdivide() > group() > firstCall() > main()順で伝播されます。ここでfirstCall関数で回復させたので、main関数までは伝播されなく、firstCallで回復されることが確認できます。

プログラムを開発する時には回復を使わないことをお勧めします。回復を使うと、プログラムが終了されなくなって問題の原因を探したり、問題を認識することが遅れてしまします。

デプロイされた状態では回復を使うしかないです。そしたら、上のように問題の原因を探したり、認識することが遅くなるのでパニックが発生したらメールでエラーの内容を転送したり、データベースに保存して問題を早めに認識できるようにしたほうが良いです。

GolangはSHEを提供してない

Golangは構造化エラー処理(SHE, Structured Error Handling)を提供してないです。

try {
  ...
} catch (Exception ex) {
  ...
} finally {
  ...
}

他のプログラミング言語ではtry-catch文を使ってエラーを処理しますが、Golnagではこれをそもそも提供してないです。SHEは次のような問題点を持ってます。

  1. 性能問題: 正常の場合もこの構造を持ってるので性能が悪くなる
  2. エラーの認識が難しいくなる。そのせいでエラー処理をしなくなったり、放置する場合が出る。

完了

これでGolangでエラーを処理する方法について見て見ました。プログラムを持続的維持しながらエラーを発生させる方法とパニックを使ってプログラムを終了させる方法、それともパニックを回復させる方法についても見て見ました。

エラーはプログラミンでかなり大事な役割をしてます。そのため、エラーをリターンする関数がある時には、空白識別子(_)で無視しなくて、必ず処理をするようにした方が良いです。パニックもプログラムが終了されないようにrecoverを使うことも良いですが、この問題を認識して処理できるような仕組み(メール、データベースなど)を一緒に使うことが良いです。

私のブログが役に立ちましたか?下にコメントを残してください。それは私にとって大きな大きな力になります!

アプリ広報

今見てるブログを作成たDekuが開発したアプリを使ってみてください。
Dekuが開発したアプリはFlutterで開発されています。

興味がある方はアプリをダウンロードしてアプリを使ってくれると本当に助かります。

Posts