Hori Blog

フリーランスでバックエンドエンジニアとして活動している Ryota Hori のブログです。
最近はテック系記事より雑記ブログ気味。

表現力の高い Go の Error 群を go generate する go-generror を作った

連日 DDD と go generate で投稿していますが、今回は表現力と安全性の高い Error 周りを生成する go-generror を作りました。

hori-ryota/go-generror

  • 対象ディレクトリ内のファイルを走査して Error 型を生成する
  • ErrorErrorDetail の 2 層構造
    • Error : error インターフェースを実装し、 ErrorCode を一つ、 ErrorDetail を複数持つ。
      • ErrorCode: HTTP の StatusCode のような使用感を想定。生成された ErrorIsFoo() bool のような判定メソッドを持つ
        • 引数を元に生成される
    • ErrorDetail : エラーの詳細定義が目的。 UI や Log に出力したい、サービス内エラー詳細。 ErrorDetailCode とパラメータを持つ
      • ErrorDetailCode : 詳細エラーのコード。コードごとに固定長の型安全なパラメータを定義する
        • //errcode のコメントを検知して生成される
  • デフォルトがカレントディレクトリなので //go:generate go-generror を対象ディレクトリに記述すれば go generate で起動する

動作サンプル

こんな感じで定義します。

example.go
package example

import "errors"

//go:generate go-generror Unknown BadRequest PermissionDenied NotFound

type NameSpec struct {
	lessThan int
	moreThan int
}

func (s NameSpec) Validate(name string) Error {
	//errcode NameIsInvalidLength,lessThan int,moreThan int
	if len(name) >= s.lessThan || len(name) <= s.moreThan {
		return ErrorBadRequest(errors.New("invalid name"), NameIsInvalidLengthError(s.lessThan, s.moreThan))
	}
	return nil
}

Validate のメソッド内に //errcode が記述されています。

こんな感じで出力されます。

error_gen.go
// Code generated ; DO NOT EDIT

package example

import (
	"fmt"
	"strconv"
	"strings"

	"github.com/hori-ryota/zaperr"
	"go.uber.org/zap"
	"go.uber.org/zap/zapcore"
)

type ErrorCode string

const (
	errorUnknown          ErrorCode = "Unknown"
	errorBadRequest       ErrorCode = "BadRequest"
	errorPermissionDenied ErrorCode = "PermissionDenied"
	errorNotFound         ErrorCode = "NotFound"
)

func (c ErrorCode) String() string {
	return string(c)
}

type Error interface {
	Error() string
	Details() []ErrorDetail

	IsUnknown() bool
	IsBadRequest() bool
	IsPermissionDenied() bool
	IsNotFound() bool
}

func newError(source error, code ErrorCode, details ...ErrorDetail) Error {
	return errorImpl{
		source:  source,
		code:    code,
		details: details,
	}
}

func ErrorUnknown(source error, details ...ErrorDetail) Error {
	return newError(source, errorUnknown, details...)
}
func ErrorBadRequest(source error, details ...ErrorDetail) Error {
	return newError(source, errorBadRequest, details...)
}
func ErrorPermissionDenied(source error, details ...ErrorDetail) Error {
	return newError(source, errorPermissionDenied, details...)
}
func ErrorNotFound(source error, details ...ErrorDetail) Error {
	return newError(source, errorNotFound, details...)
}

type errorImpl struct {
	source  error
	code    ErrorCode
	details []ErrorDetail
}

func (e errorImpl) Error() string {
	return fmt.Sprintf("%s:%s:%s", e.code, e.details, e.source)
}
func (e errorImpl) Details() []ErrorDetail {
	return e.details
}

func (e errorImpl) IsUnknown() bool {
	return e.code == errorUnknown
}
func (e errorImpl) IsBadRequest() bool {
	return e.code == errorBadRequest
}
func (e errorImpl) IsPermissionDenied() bool {
	return e.code == errorPermissionDenied
}
func (e errorImpl) IsNotFound() bool {
	return e.code == errorNotFound
}

type ErrorDetail struct {
	code ErrorDetailCode
	args []string
}

func newErrorDetail(code ErrorDetailCode, args ...string) ErrorDetail {
	return ErrorDetail{
		code: code,
		args: args,
	}
}

func (e ErrorDetail) String() string {
	return strings.Join(append([]string{e.code.String()}, e.args...), ",")
}

type ErrorDetailCode string

func (c ErrorDetailCode) String() string {
	return string(c)
}

const ErrorDetailNameIsInvalidLength ErrorDetailCode = "NameIsInvalidLength"

func NameIsInvalidLengthError(
	lessThan int,
	moreThan int,
) ErrorDetail {
	return newErrorDetail(
		ErrorDetailNameIsInvalidLength,
		strconv.FormatInt(int64(lessThan), 10),
		strconv.FormatInt(int64(moreThan), 10),
	)
}

func (e errorImpl) MarshalLogObject(enc zapcore.ObjectEncoder) error {
	zaperr.ToNamedField("sourceError", e.source).AddTo(enc)
	zap.String("code", string(e.code)).AddTo(enc)
	zap.Any("details", e.details)
	return nil
}

モチベーション

  • ドメイン駆動設計(以下 DDD )をやっていきたい
  • DDD ではドメイン層で対象領域の概念をコードで表現することが重要
  • 本質以外の作業はノイズなので人間にやらせたくない
  • Go の error は表現力に乏しく、ドメイン層でのエラーは独自定義が望まれる
    • 上層で型安全でないアサーション地獄をするのは Type-safe な言語のメリットを消している
  • サービス開発で特にコミュニケーション&作業コストが高い領域なので自動生成の恩恵が大きい
    • メンテが後手に回りがちなドキュメントとにらめっこしがち
    • ドキュメントを読んで型安全でない string とパラメータ群の switch 文。つらい。
      • 副産物としてドキュメントを提供していきたい
      • 副産物として型安全な Formatter 等を提供していきたい

ということで go generate で自動生成することにしました。使用感は良さそう。

今回は「この Error 型が正しい!」という話ではなく、エラー定義を parse して成果物を generate することのメリットの話が重要なポイントです。

エラー定義から成果物を generate するメリットについて

エラー出力は以下のような理由によりメンテナンスコストが高く、型安全でなく、テストしづらいものだと思います。

  • サービス仕様の変更により種類が増減しやすい
  • 増減が多いこともありドキュメントの更新が漏れやすい
  • 正常系テストでは生じないものも多く、 backend 実装者のモチベーションが低くなりがち
  • 新しいエラーを作るのが面倒で既存エラーに便乗したくなりがち
  • 可変な値を出力文言に組み入れたいが、対応コストが高くなりがち
    • バリデーションロジックとの整合性担保が悩ましい
    • 型安全でなく、エラー種ごとに switch 文などで対応しがち
    • 型安全でなく、多言語対応での対応漏れ検知が難しい
  • 仕様書との対応確認が目視
  • 使っていなさそうなエラー種が消していいのかわからない

ちゃんとモチベーション高く管理すれば全て解決はできると思いますが、異常系対応のコストはなるべく低く保ちたいです。

欲しいものは以下と考えます。

  • backend として
    • 安全に補完の恩恵をうけつつエラー生成したい
    • 定義だけ書きたい。型安全にする系の退屈な実装は定義から作っといて欲しい
    • 使用箇所で気軽に定義したい(含:ドキュメントの更新など)
  • frontend として
    • 安全に補完の恩恵をうけつつエラー対応したい
    • 事情が変わったら適切に検知したい
    • 対応すべきものが把握しやすいのがいい
    • 文言管理はまとめたい
    • 多言語対応したい

backend 実装箇所でのエラー定義生成とすることで、上記の欲求は以下のように改善されます。

  • backend として
    • 安全に補完の恩恵をうけつつエラー生成したい
      • 型安全な生成コードで Type-safe の恩恵に預かれる
    • 定義だけ書きたい。型安全にする系の退屈な実装は定義から作っといて欲しい
      • 定義から実装を生成するので楽
    • 使用箇所で気軽に定義したい(含:ドキュメントの更新など)
      • 実装箇所に定義を書けば邪魔にならないところに生成される
      • ドキュメントなども必要であれば然るべきところに自動生成できる
  • frontend として
    • 安全に補完の恩恵をうけつつエラー対応したい
      • 型安全な Formatter interface を生成すれば Type-safe の恩恵に預かれる
    • 事情が変わったら適切に検知したい
      • 型安全な Formatter interface を生成すれば Type-safe の恩恵に預かれる
    • 対応すべきものが把握しやすいのがいい
      • 型安全な Formatter interface を生成すれば Type-safe の恩恵に預かれる
      • ドキュメントなども必要であれば然るべきところに自動生成できる
    • 文言管理はまとめたい
      • Formatter interface を生成し、実装をまとめれば OK
    • 多言語対応したい
      • Formatter interface の実装を言語ごとに実装すれば OK

go-generror では generror.Run を exported 定義しているので、独自定義の main.go を用意すればデフォルトファイル以外の生成が可能です。

main.go
templates := []template.Template{
    generror.GodefTmpl,
    customTmpl1,
    customTmpl2,
}

renderers := make([]func(generror.TemplParam), len(templates))
for i := range templates {
    tmpl := tempates[i]
    dstFileName := dstFileNames[i]
    renderers[i] = func(param generror.TmplParam) error {

        param.ImportPackages = append(param.ImportPackages, "fmt")
        param.ImportPackages = append(param.ImportPackages, "strings")
        param.ImportPackages = append(param.ImportPackages, "go.uber.org/zap")
        param.ImportPackages = append(param.ImportPackages, "go.uber.org/zap/zapcore")
        param.ImportPackages = append(param.ImportPackages, "github.com/hori-ryota/zaperr")

        buf := new(bytes.Buffer)
        err := tmpl.Execute(buf, param)
        if err != nil {
            return err
        }

        out, err := format.Source(buf.Bytes())
        if err != nil {
            return err
        }
        return ioutil.WriteFile(dstFileName, out, 0644)
    }
}

return generror.Run(".", args[1:], renderers)

よって、以下のようなテンプレートを用意することで(サンプルは Go ですが) Formatter を生成することができます。

customTmpl.go
type ErrorFormatter interface {
    {{- range .DetailErrorCodes }}
    {{ .Code }}Error(
        {{- range .Params }}
        {{ .Name }} {{ .Type }},
        {{- end }}
    ) string
    {{- end }}
}

type ErrorDetail struct {
    Code string
    Args []interface{}
}

func FormatError(formatter ErrorFormatter, err ErrorDetail) string {
    switch err.Code {
    {{- range .DetailErrorCodes }}
    case "{{ .Code }}":
        return formatter.{{ .Code }}Error(
            {{- range $i, $v := .Params }}
            err.Args[{{ $i }}].({{ $v.Type }}),
            {{- end }}
        )
    {{- end }}
    }
}

generated

error_gen.go
type ErrorFormatter interface {
	NameIsInvalidLengthError(
		lessThan int,
		moreThan int,
	) string
	FooError(
		arg1 string,
		arg2 time.Time,
		arg3 int,
	) string
	BarError() string
}

type ErrorDetail struct {
	Code string
	Args []interface{}
}

func FormatError(formatter ErrorFormatter, err ErrorDetail) string {
	switch err.Code {
	case "NameIsInvalidLength":
		return formatter.NameIsInvalidLengthError(
			err.Args[0].(int),
			err.Args[1].(int),
		)
	case "Foo":
		return formatter.FooError(
			err.Args[0].(string),
			err.Args[1].(time.Time),
			err.Args[2].(int),
		)
	case "Bar":
		return formatter.BarError()
	}
}

この型安全な ErrorFormatter を実装することで各種メリットを享受できるようになります。通信層での型変換も自動生成が可能です。

ただのテンプレート生成なので Go 言語以外の他言語にも工夫次第で対応できます(個人的には Kotlin 対応を試し中です)。

以上

これでドメインの重要な表現であるエラー周りについても、見やすく楽に、表現力は高く定義することが可能になりました。ドメイン層での概念の表現に集中することは非常に重要なため、大きなメリットとなります。

Go は基本的に error インターフェースを推奨しますが、それは内部実装に依存せずに汎用的な error として扱うことを呼び出し元に求めることが前提です。呼び出し元に型安全を求めるのにわざわざ表現力を落とした error インターフェースで抽象化して型アサーションする必要はないし、デメリットが大きいと思っています。

さて、代表的な「型の恩恵を受けたいが丁寧にやると手が疲れる」概念である error と event の前者が達成できたので、次は event を仕込む予定です。

event はその特性上、 generics が無いことや複数送受信などで実装が複雑になりがちですが、 generator を使うことで効率化と快適さを得ることができるはずです。

乞うご期待。