カテゴリー: BackEnd

Go言語のエラーハンドリングとログローテーション

はじめに

こんにちは、今回はGo言語でのエラーハンドリングとログ関連についての記事となります。

例外のないGo言語でどのようにエラーを伝搬させていくのかについて一つの方法を紹介させていただき、その後、そのエラーをファイルに出力する方法を紹介したいと思います。
なお、開発環境の構築がまだの方は、ぜひこちらで環境構築をしてからお読みいただければと思います。

エラーハンドリング

エラーハンドリングについてですが、まずはGo言語でerrorとは一体何ものなのかについて触れたいと思います。その後、pkg/errorsパッケージでerrorをwrapをする方法を記載し、次の節でそれを活用した独自のerrorsパッケージを作成したいと思います。

error インターフェース

Go言語では例外の概念がないため(Go2では例外が組み込まれるかもしれませんが)、ファイルの処理などで下記のようにerrorを返却するコードをよく目にすると思います。

fileName := "test.csv"
f, err := os.Open(fileName)
if err != nil {
    return nil, err
}

この os.Open関数で返却されるerrorとは何かと言うと、error インタフェースを実装した構造体となります。下記にerrorインタフェースの定義を記載しておきます。

// The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
    Error() string
}

そして、errors.New関数で生成され、返却されるものは、上記を実装した構造体の一つになります。標準パッケージのerrors.goの内容も掲載しておきます。

// 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として扱えるということになり、本ブログでもその特性を利用して、独自のerrorsパッケージの作成を後半で行います。

pkg/errors パッケージ

error関連のパッケージで、pkg/errorsパッケージを少しだけ紹介したいと思います。これは、errorを呼び出し元に返却する際に元のerrorをwrapすることで、errorをstackさせていけるようにするためのシンプルなパッケージです。

例えば、先ほどos.Openのエラーハンドリングを記載しましたが、pkg/errorsを利用すると下記のようにerrorをstackさせることができます。

fileName := "test.csv"
f, err := os.Open(fileName)
if err != nil {
    return nil, errors.Wrap(err, "open failed: file, name is "+fileName)
}

それでは、このpkg/errorsパッケージを利用して、独自のerrorsパッケージを作成してみたいと思います。

独自のエラータイプ付き errorsパッケージを作成

先ほどの節で、errorインタフェースを実装した構造体であれば、errorとして扱えるということがわかったと思います。ただ、文字列しか表現できないerrorでは何かと扱いにくい場合もあると思います。そこで、今回は一例としてエラータイプというものを持たせたerrorをpkg/errorsパッケージを利用して、作成したいと思います。早速コードの全量になります。

package errors

import (
    "fmt"
    "github.com/pkg/errors"
)

const (
    Unknown = ErrorType(iota)
    DBAccess
    APIAccess
)

type ErrorType uint

func (e ErrorType) New(msg string) error {
    return typedError{errorType: e, originalError: errors.New(msg)}
}

func (e ErrorType) Newf(msg string, args ...interface{}) error {
    err := fmt.Errorf(msg, args...)
    return typedError{errorType: e, originalError: err}
}

func (e ErrorType) Wrap(err error, msg string) error {
    return e.Wrapf(err, msg)
}

func (e ErrorType) Wrapf(err error, msg string, args ...interface{}) error {
    newErr := errors.Wrapf(err, msg, args...)
    return typedError{errorType: e, originalError: newErr}
}

type typedError struct {
    errorType     ErrorType
    originalError error
}

func (error typedError) Error() string {
    return error.originalError.Error()
}

func GetType(err error) ErrorType {
    if typedError, ok := err.(typedError); ok {
        return typedError.errorType
    }

    return Unknown
}

それほどコード量がないのでみていただければ、だいたい理解できると思いますが、このerrorsパッケージの特徴を簡単に箇条書きで記載しておきます。

  • errorインタフェースを実装したtypedError構造体を定義し、そこにエラーそのものとエラータイプを保持させた
  • typedError構造体をパッケージスコープにして、外部から直接生成できないようにした(エラータイプはGetType関数で取得)
  • ErrorTypeを定義して、特定のエラータイプのerrorを生成する関数郡を用意した
  • pkg/errorsを使い、errorをstackできるようにした

なお、このerrorsパッケージの利用例については、最後の節でエラーの内容をログファイルに出力させるサンプルで紹介します。

log パッケージ

この節では、上記のエラーをファイル出力や標準出力するためのログについて記載していきます。標準でもlogパッケージが提供されていますが、ログレベルの調整などを行う場合には手間がかかるため、githubで公開されているパッケージを利用しようと思います。

log パッケージのGitHubスター数

スター数が1,000を超えている有名どころのlog関連のパッケージのいくつかを下記に紹介します。(※2018/11/02時点)

name star latest commit
zap 5,427 2018/08/15
go-kit 11,688 2018/11/01
zerolog 1,464 2018/11/01
glog 2,003 2016/01/26
logrus 8,805 2018/11/02

この中では、zapが高速であることをおしており、私も実際にベンチマークをしたところ、logrusと比べ数倍は早い結果になりました。ただ、今回はそれほどログを大量に出力することは考慮せず、もっとも使い勝手がよく開発も活発なlogrusで独自のloggersパッケージを作ることにしました。

logrus パッケージ

このパッケージについては、ほとんど説明することなるすぐに利用ができます。こちらのREADMEにある通り、「ログのフォーマット(SetFormatter)」「ログの出力先(SetOutput)」「ログの出力レベル(SetLevel)」を設定して、あとはInfoやErrorなどの関数で出力を行うだけです。実際のサンプル例はログローテーションと合わせて後の節で記載します。

ログローテーション

先ほど紹介した全てのlogパッケージは、ログをファイルに出力してもログローテーションまではしてくれません。つまり、一つのファイルに永遠と出力を続け、放っておくと巨大なサイズのファイルができあがります。

ログローテーションのやり方としては、Linuxなどの環境にインストールされているlogrotateパッケージをデプロイ先のサーバで設定するというのがあります。ただ、これだとGo言語で開発しているプロジェクト内でコントロールできないので、今回はlumberjackを使って、ソースコード内で設定をしようと思います。

lumberjack パッケージ

このパッケージも利用方法はとてもシンプルで、こちらのREADMEを読むだけですぐに設定できます。
設定項目としては、「ファイル名(Filename)」「ローテーションするファイルサイズ(MaxSize)」「古いログを保持する日数(MaxAge)」「保持する古いログの最大ファイル数(MaxBackups)」「バックアップファイルの時刻フォーマットをサーバローカル時間にするかどうか(LocalTime)」「ローテーションされたファイルをgzip圧縮するかどうか(Compress)」があります。こちらも実際のサンプル例は次の節でlogrusと合わせて紹介します。

独自のloggersパッケージを作成

それでは、logrus と lumberjackの利用して、独自のloggersパッケージを作成してみたいと思います。とてもシンプルなので、いきなり全量のコードをはります。

package loggers

import (
    "github.com/sirupsen/logrus"
    "gopkg.in/natefinch/lumberjack.v2"
    "os"
)

const (
    envProduction  = "production"
    envStaging     = "staging"
    envDevelopment = "development"
)

func init() {
    var environment string
    switch os.Getenv("GO_ENV") {
    case envProduction, envStaging, envDevelopment:
        environment = os.Getenv("GO_ENV")
    default:
        environment = envDevelopment
    }

    logrus.SetFormatter(&logrus.JSONFormatter{})
    if environment == envProduction {
        logrus.SetLevel(logrus.InfoLevel)
    } else {
        logrus.SetLevel(logrus.DebugLevel)
    }

    if environment == envDevelopment {
        logrus.SetOutput(os.Stdout)
    } else {
        logrus.SetOutput(&lumberjack.Logger{
            Filename:  "log/" + environment + ".log",
            MaxAge:    365,
            LocalTime: true,
            Compress:  true,
        })
    }
}

func Fatal(args ...interface{}) {
    logrus.Fatal(args...)
}

func Fatalf(format string, args ...interface{}) {
    logrus.Fatalf(format, args...)
}

func Error(args ...interface{}) {
    logrus.Error(args...)
}

func Errorf(format string, args ...interface{}) {
    logrus.Errorf(format, args...)
}

func Info(args ...interface{}) {
    logrus.Info(args...)
}

func Infof(format string, args ...interface{}) {
    logrus.Infof(format, args...)
}

func Debug(args ...interface{}) {
    logrus.Debug(args...)
}

func Debugf(format string, args ...interface{}) {
    logrus.Debugf(format, args...)
}

上記の解説を箇条書きで記載します。

  • init関数の中でlogrusの初期化処理をlumberjackも使い、全て完了させた
  • 後の関数はlogrusをラップしただけ。(他のコードでlogrusを直接意識して触らせないためです)
  • logrusのファイル出力はJSON形式とし、ログレベルとログの出力先は環境変数によって分けた
  • lumberjackは、ファイル名を環境変数によって決め、1年間ログを保持し、古いファイルは圧縮させた

独自のerrors と loggers でエラーログ出力

それでは最後に、上記で紹介した独自のerrorsパッケージとloggersパッケージを利用するサンプルコードと実行結果を紹介します。

まず、下記が 上記の2パッケージを利用するmain.goファイルになります。mysqlサーバからuserレコードを取得する際にエラーが発生したということを擬似的に表現しています。

package main

import (
    pkgErrors "errors"
    "fmt"
    "github.com/re-engines/hello_go/errors"
    "github.com/re-engines/hello_go/loggers"
    "os"
)

type UserModel struct{
}

func (db *UserModel) selectNameBy(id int) (record string, err error) {
    err = pkgErrors.New("cannot connect to mysql server")
    return
}

func fetchUserNameBy(id int) (userName string, err error) {
    userModel := UserModel{}
    userName, err = userModel.selectNameBy(1)
    if err != nil {
        err = errors.DBAccess.Wrap(err, "cannot select user_name from users table")
    }
    return
}

func main() {
    userName, err := fetchUserNameBy(1)
    if err != nil {
        loggers.Error(err.Error())
        switch errors.GetType(err) {
        case errors.DBAccess:
            fmt.Println("現在サーバで問題が発生しています。")
        case errors.APIAccess:
            fmt.Println("外部サービス側で何か問題が発生している可能性があります。")
        case errors.Unknown:
            fmt.Println("予期せぬ問題が発生しています。")
        }
        os.Exit(1)
    }

    fmt.Println("hello, " + userName)
}

実行結果と出力されたログファイルの中身を記載します。

$ GO_ENV="production" go run main.go 
現在サーバで問題が発生しています。
exit status 1
$ cat log/production.log 
{"level":"error","msg":"cannot select user_name from users table: cannot connect to mysql server","time":"2018-11-02T06:58:36+09:00"}

特筆すべきところとしては、switch文でログタイプにより処理が分岐できている点と、ログファイルのmsgの中身がerrorのstackを出力できている点です。これで、どのようにエラーが伝搬して最終的にログファイルに書き込まれたのかがわかるようになっています。

さいごに

いかがでしたでしょうか。エラーハンドリングから、そのエラー内容をログに出力させ、そしてログファイルをローテーションさせるまで一気に説明させていただきました。これをベースにもう少し使い勝手をよくしたり、また必要なログ情報を付け加えたりしていこうと思います。また、logrus から zapに切り替えるなどもやってみたいと思います。

なお、Go言語に関してはこちらでも多くの記事を紹介していますので、ぜひご覧ください。

おすすめ書籍

      

原弘

twitter: @hir_hara

シェア
執筆者:
原弘
タグ: Go言語golang

最近の投稿

フロントエンドで動画デコレーション&レンダリング

はじめに 今回は、以下のように…

3週間 前

Goのクエリビルダー goqu を使ってみる

はじめに 最近携わっているとあ…

1か月 前

【Xcode15】プライバシーマニフェスト対応に備えて

はじめに こんにちは、suzu…

2か月 前

FSMを使った状態管理をGoで実装する

はじめに 一般的なアプリケーシ…

3か月 前