Go言語におけるエラーハンドリングを今一度振り返る

こんにちは! エンジニアの臼井です。

この記事は、 eureka Advent Calendar 2017 2日目の記事です。

昨日は、梶原さんの ”モダンな情報システム”を目指しているeurekaが、今年で一番変わったお話をしたい。 という記事でした。

今日の記事では、Go言語のエラーハンドリングについて、標準のerrorインターフェースについて他言語における例外機構と比較しながら考えていきたいと思います。

error インターフェース

はじめに、Go言語には例外が存在せず、関数やメソッドが複数の返り値を返すことが可能であることを利用して、以下の様なイディオムでエラーハンドリングを実行することを示します。

複数の返り値の最後の値を error インターフェースを満たす型で返し、呼び出し元でそのエラーを元に処理を行います。panic-defer-recover を使用したエラーハンドリングは多くの場合推奨されません。

エラーが発生していない場合は nil を返却し、処理が正常終了したことを知らせます。

Go言語には正常系のエラー処理機構はこの error インターフェースを用いたものしか存在しないため、コードのそこかしこにこのイディオムが現れます。

これまでに書いた if err != nil の数は思い出すことは非常に困難です……

他言語における例外機構

さて、他言語における例外機構ですが、C#のメソッドのコード断片をサンプルコードとして利用します。
class や namespace の記述を省略しますが、基本的な構文は以下の様な感じになると思います。

整数の配列を引数にとり、その商を返す単純な関数ですが、わざとゼロ除算や配列インデックス外の参照が発生する可能性を持たせ、例外のメッセージを標準出力に出力した後 rethrow しています。

呼び出し元では発生したエラーのタイプによって例外の型が異なるため、それに応じたエラー処理が可能です。

error インターフェースと例外機構の比較

さて、ここでGo言語のエラー構文に戻ります。

error インターフェースを満たす型が返ってきて、その有無でハンドリングしているだけに見えます。

ここで、error インターフェースの実装を確認してみましょう。

type error interface {
Error() string
}

エラー文字列を返す Error() メソッドだけ実装されていればよいというシンプルなものです。

標準の errors パッケージにはデフォルトのエラー型を生成する関数が存在します。

実体を見ると、エラー文字列のフィールドだけを持つ構造体で、New 関数にてエラー文字列がセットされた構造体それを生成しそのポインタが得られます。

エラーを生成するときによく用いる fmt パッケージの Errorf() 関数は、この関数のラッパーです。

https://github.com/golang/go/blob/master/src/errors/errors.go#L1-L20

// Copyright 2011 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package errors implements functions to manipulate errors.
package errors
// New returns an error that formats as the given text.
func New(text string) error {
return &errorString{text}
}
// errorString is a trivial implementation of error.
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}
なんらかのエラーが発生した場合、標準の error インターフェースとして扱う場合は Error() メソッドによるエラー文字列の取得のみが可能となっています。

つまり他言語の例外機構のように、try-catch-finally句による制御や、例外クラスが提供する豊富なプロパティとメソッドによるエラーに関する情報の取得や通知は、そのままで行うことは難しくなっています。
標準パッケージにおけるエラー処理の例
net パッケージはネットワークレイヤ(TCP/IP,DNS, Unixドメインソケット)に関する機能を提供する標準パッケージで、他言語のようにエラーの種類によりいくつかのエラー型が存在します。これらを以下に示します。
  • AddrError
  • DNSConfigError
  • DNSError
  • InvalidAddrError
  • OpError
  • ParseError
  • UnknownNetworkError
これらの型は net パッケージ内で宣言される以下の net.Error インターフェースを満たします。
type Error interface {
error
Timeout() bool // Is the error a timeout?
Temporary() bool // Is the error temporary?
}
  • 基底の例外クラス
  • 上記を継承した名前空間・パッケージ単位での例外クラス、あるいはその言語におけるインターフェース
  • 具体的なエラーを表すさらに上記を継承した例外クラス
に相当するオブジェクトがGo言語の標準パッケージにも存在するということです。

ここで、 DNSError が使われている箇所について具体的に見てみます。

https://github.com/golang/go/blob/master/src/net/dnsclient.go#L12-L18
// reverseaddr returns the in-addr.arpa. or ip6.arpa. hostname of the IP
// address addr suitable for rDNS (PTR) record lookup or an error if it fails
// to parse the IP address.
func reverseaddr(addr string) (arpa string, err error) {
ip := ParseIP(addr)
if ip == nil {
return "", &DNSError{Err: "unrecognized address", Name: addr}
}
}
IPアドレスからのDNS逆引き検索処理の関数で、引数で与えられるIPアドレスが想定外であれば、DNSError 型にエラーメッセージと引数でそれぞれ対応するフィールドを初期化し、 error として返します。

呼び出し元が処理の失敗時の詳細についてどこまで知りたいかによりますが、失敗の検知とログ出力で十分ならば、 error をそのまま利用するのみで問題ありません。

厳格にDNS関連の処理のエラーであるとして処理を続行したいならば、以下の様に型アサーションを行い DNSError 型であるという情報を用いて例外処理のcatch句のような処理も可能でしょう。
https://gist.github.com/yu81/d4913af295a4374101f619315a177de3
エラー処理の実際
現実のGo言語のアプリケーションコードにおいて、常に具象の error 型を定義するかというとそういうことはなく、エラー発生時のエラー文字列とエラーコードをフィールドに持つ汎用的な型を使用することで事足りることもあります。

しかし、ミドルウェアやAWSなどの外部インフラ・APIへのアクセス、アプリケーション層内の処理のみで純粋に完結しないエラーがある場合に少し問題があります。

具体的には、以下の様に、
  • 外部APIが5xxエラー (インフラ層)
  • APIからデータ取得失敗しているのでその後のDB更新ができない(ドメイン層)
  • ……
と、インフラ層で外部APIアクセスに関するエラーが発生した場合を考えます。

このエラー内容を上位層に伝播させていく場合に、error を適切なフォーマットで文字列連結して新しい error を生成して伝えるなど、そのままではエラーに関する情報の伝達が煩雑になります。

また、最初に述べたように Go言語の基本の error 型は文字列のフィールドのみを持つシンプルな構造であり、スタックトレースが保持できないため、デバッグが困難になることがあります。

この問題の解決の為に、pkg/errors パッケージを利用します。

golang.org/pkg/ (ドキュメント) https://github.com/pkg/errors (リポジトリ) はGo言語のメインリポジトリとは独立していますが、公式により提供されている標準に近いライブラリ群です。
pkg/errors
単純にスタックトレースと各々の層でのエラーメッセージを保持するだけのケースであれば、以下の様にパッケージ関数を用います。
  • エラーが初めて発生した時、pkg/errors.New または Errorf 関数で新規エラー作成して返す。
  • 上層でのエラーメッセージ付与は、下層のエラーを pkg/errors.WrapWrapf 関数で追加して返す。
  • ログ出力等でスタックトレースを表示する場合、 pkg/errors.Cause 関数で発生元のエラーとスタックトレースを抽出し、出力する。
得られたエラーに対して %+v 書式を指定すると、以下の様にメッセージとスタックトレースが得られます。

このスタックトレースは、pkg/errors 内で runtime.Caller 関数を利用して得たものが使われます。
ErrorMessage
Package1.FuncName1
/path/to/file1.go:LineNo1
Package2.FuncName2
/path/to/file2.go:LineNo2
...
得られたエラーをそのまま出力すると、各層で Wrap されたエラーがそのまま全て出力されてしまうので、スタックトレースと直近のエラーメッセージのみで十分ならば pkg/errors.Cause 関数で取り出したエラーのみを利用するとよいでしょう。
pkg/errors と例外機構
Go言語のエラーハンドリングについて、標準に近い方法で例外機構のようなエラーハンドリングを行うための手段についてコードを見ながら考えました。

pkg/errors を利用してスタックトレースを得て、エラーの内容に応じたエラー型を用いることで、ある程度類似のことは出来ることがわかります。

ただ、元々のエラーハンドリングがシンプルであったがゆえに、これらを正しく遂行しようとすると、記述量もどんどん増えていくことが予想されます。

if err != nil {return err} に加えて、switch (err) {case err.(SomeError):...} という処理がエラー型の数と実行箇所の分あらわれてきます。

Go言語はそのシンプルな言語仕様が一つの大きな魅力であり利点ですが、通常の例外機構レベルのリッチなエラーハンドリングを常に実現しようとすると、アプリケーションコードがエラーハンドリングで埋め尽くされてしまうのではないでしょうか。

これに対して取るべき道はいくつもあると考えられます。
  • 標準 error 型のみで常にシンプルに処理する。
  • pkg/errors の使用と各種エラー型の定義を必ず行い、例外機構にできるだけ近い厳格な処理を行う。
  • pkg/errors の使用と各種エラー型定義を行うが、部分的なものとする。
  • 標準 error 型のみで処理した場合運用が困難になるアプリケーションをGo言語で記述しない。
  • etc…
現実的には、3つめの部分的な pkg/errors と 各種エラー型の導入であると思いますが、Go言語らしさを考えると4つめの選択肢もまたひとつあり得るのではないかと考えます。

Go言語らしさとは、というところに議論があると思いますが、標準から大きく外れた使い方をすると生産性の低下や困難が発生しやすく、良くも悪くもこれであるのかなと考えています。

基本的にオブジェクト指向設計と実装が可能なGo言語ですが、標準の機構が極めてシンプルなため、このような議論が発生し得ます。

あえてシンプルにすることで、適するユースケースを浮き彫りにしているのかもしれません。
おわりに
Go言語におけるエラーハンドリングについて、例外機構と比較しつつ pkg/errors と具象のエラー型を用いたエラーハンドリングについて概観し、どこまでこれを適用すべきかということについて書きました。

Go言語もエウレカがPairsのサーバサイドフルスクラッチ導入の時期と比べると、利用者も導入組織も大きく増えていると感じます。

利用者が増えるほどユースケースも増え、開発ツールとしてのプログラミング言語に求められることも変わってくるのではないかなと考えています。
明日はBIチーム大久保さんの、 分析クエリを「速く・楽に・正確に」書くためにスニペットに登録すべきもの5選 です。お楽しみに!
参考資料
Like what you read? Give eureka_developers a round of applause.

From a quick cheer to a standing ovation, clap to show how much you enjoyed this story.