LoginSignup
1
1

GoでEnumを自前実装したら結構使えた話

Last updated at Posted at 2023-12-05

この記事は Go 言語 Advent Calendar 2023 6日目の記事です。

はじめに

GoでEnumってどう実現するんだという話をSNSでたびたび目撃します。
GoとEnumについてにベストプラクティスは紹介されていますが、今回は私の関わっているチームで自前実装した方式を紹介します。

すでに本番導入している方式で、数年間本番で追加開発/保守運用する中でもうまく機能していると感じています。

実装したもの

こんな感じです。
定義ファイルから以下のコードを生成して使っています。

order_status_gen.go
// Code generated; DO NOT EDIT.

// 追加でメソッドを定義したい場合は、別ファイルでメソッドを追加してください。
// 別ファイルはこのファイルから、「_gen」を取った名前で作成してください。
// 例:hogehoge_gen.go -> hogehoge.go

package enum

import (
	"fmt"
	"sort"
	"strconv"
)

type OrderStatusEnum string

var OrderStatus = struct {
	Requested      OrderStatusEnum
	WorkInProgress OrderStatusEnum
	Done           OrderStatusEnum
	Canceled       OrderStatusEnum
	opts           map[string]map[string]interface{}
}{
	Requested:      OrderStatusEnum("requested"),
	WorkInProgress: OrderStatusEnum("work_in_progress"),
	Done:           OrderStatusEnum("done"),
	Canceled:       OrderStatusEnum("canceled"),
	opts: map[string]map[string]interface{}{
		"requested": {
			"ja":            "依頼中",
			"ordinal":       "0",
			"is_cancelable": true,
		},
		"work_in_progress": {
			"ja":            "作業中",
			"ordinal":       "1",
			"is_cancelable": false,
		},
		"done": {
			"ja":            "完了",
			"ordinal":       "2",
			"is_cancelable": false,
		},
		"canceled": {
			"ja":            "キャンセル",
			"ordinal":       "3",
			"is_cancelable": false,
		},
	},
}

// ToOrderStatus は引数に該当するOrderStatusEnumを返却する。
// 該当するOrderStatusがない場合、nilを返す。
func ToOrderStatus(val string) (*OrderStatusEnum, error) {
	switch val {
	case OrderStatus.Requested.String():
		return &OrderStatus.Requested, nil
	case OrderStatus.WorkInProgress.String():
		return &OrderStatus.WorkInProgress, nil
	case OrderStatus.Done.String():
		return &OrderStatus.Done, nil
	case OrderStatus.Canceled.String():
		return &OrderStatus.Canceled, nil
	}
	return nil, fmt.Errorf("OrderStatusEnum への変換に失敗しました value: %v", val)
}

// AllOrderStatusValues は OrderStatusEnum の一覧を返す。
func AllOrderStatusValues() []OrderStatusEnum {
	values := []OrderStatusEnum{
		OrderStatus.Requested,
		OrderStatus.WorkInProgress,
		OrderStatus.Done,
		OrderStatus.Canceled,
	}

	sort.Slice(values, func(i, j int) bool {
		ordinalI, _ := strconv.Atoi(values[i].Ordinal())
		ordinalJ, _ := strconv.Atoi(values[j].Ordinal())
		return ordinalI < ordinalJ
	})

	return values
}

// Equals は引数と同じOrderStatusEnumであるかを判断する。
// 同じ場合にtrueを返す。
func (e *OrderStatusEnum) Equals(obj OrderStatusEnum) bool {
	return *e == obj
}

// In は引数の中に同一のOrderStatusEnumがあればtrueを返す。
func (e *OrderStatusEnum) In(objs ...OrderStatusEnum) bool {
	for _, obj := range objs {
		if e.Equals(obj) {
			return true
		}
	}

	return false
}

// String はOrderStatusのValueを返す。
func (e *OrderStatusEnum) String() string {
	return string(*e)
}

// Ja はOrderStatusEnumのjaの属性を返す。
func (e *OrderStatusEnum) Ja() string {
	return OrderStatus.opts[e.String()]["ja"].(string)
}

// Ordinal はOrderStatusEnumのordinalの属性を返す。
func (e *OrderStatusEnum) Ordinal() string {
	return OrderStatus.opts[e.String()]["ordinal"].(string)
}

// IsCancelable はOrderStatusEnumのis_cancelableの属性を返す。
func (e *OrderStatusEnum) IsCancelable() bool {
	return OrderStatus.opts[e.String()]["is_cancelable"].(bool)
}

これらのEnumファイルはenumパッケージ内に全て配置するようにしています。
image.png

このEnumを使うとこんなふうにコードが書けます。

sample.go
package main

import (
	"fmt"
	"github.com/foo/bar/enum"
)

func main() {
	status := enum.OrderStatus.Requested // enum -> OrderStatus -> Requested という順にコード補完できる。

	if status.In(enum.OrderStatus.Done, enum.OrderStatus.Canceled) { // enum.OrderStatusEnum 型を引数にしているので他の型を渡すとコンパイルエラーになる。
		// hogehoge
	}

	echo(status)
}

func echo(status enum.OrderStatusEnum) { // enum.XxxxxEnum という型で引数定義。
	fmt.Println(status)
}

推しどころ

  • enum. と打つとコード補完でかなり絞られるのでEnum名を覚えてなくても探し出しやすいです。
    image.png

  • コード生成にプラスして以下のように_genなしのファイルを作成してメソッドを追加することでenum->enum変換機能なども実装できます。例えば FooTypeからBarGroupへのEnum変換も以下のように定義できます。テストで変換定義漏れの検知可能です。

foo_type.go
package enum

func (e *FooTypeEnum) ToBarGroup() BarGroupEnum {
	bar, err := ToBarGroup(e.BarGroupString)
	if err != nil {
		panic(err) // ユニットテストでpanicしないことを担保している。
	}
	
	return bar
}
foo_type_test.go
package enum

import (
	"testing"
)

func TestFooTypeEnum_ToBarGroup(t *testing.T) {
	for _, typ := range AllFooTypeValues() {
		t.Run(typ.String(), func(t *testing.T) {
			// panic にならないかのテスト。
			t.Log(typ.ToBarGroup())
		})
	}
}
bar_group_gen.go
bar_group_gen.go
// 省略
var BarGroup = struct {
	Hoge      BarGroupEnum
	Piyo BarGroupEnum
	opts           map[string]map[string]interface{}
}{
// 省略
foo_type_gen.go
foo_type_gen.go
package enum

import (
	"fmt"
	"sort"
	"strconv"
)

type FooTypeEnum string

var FooType = struct {
	BarHoge1 FooTypeEnum
	BarHoge2 FooTypeEnum
	BarPiyo1 FooTypeEnum
	opts: map[string]map[string]interface{}{
		"bar_hoge1": {
			"ja":               "ばーほげ1",
			"ordinal":          "0",
			"bar_group_string": "hoge",
		},
		"bar_hoge2": {
			"ja":               "ばーほげ2",
			"ordinal":          "1",
			"bar_group_string": "hoge",
		},
		"bar_piyo1": {
			"ja":               "ばーぴよ1",
			"ordinal":          "2",
			"bar_group_string": "piyo",
		},
	},
}
// 省略

注意点

  • Enumファイル(order_status_gen.goなど)は定義ファイルからコード生成しているのでmapの存在チェックなどはしていないです(template情報は後述しています)。

  • const 定義ではないのでEnum値は上書き可能です。ここはコードレビューと開発者の良心で秩序を保っています。今まで上書きしてバグったという事故は0件です。

func main() {
	fmt.Println(enum.OrderStatus.Requested) // requested

	enum.OrderStatus.Requested = "canceled" // こんなことしちゃダメ!!

	fmt.Println(enum.OrderStatus.Requested) // canceled
}

コード生成テンプレート

私の関わっているチームでは以下のような定義情報をパースして後述するEnumGeneratorに値をセットをしています。
image.png

コードテンプレートを記載しておきます。

enum.tmpl
enum.tmpl
// Code generated; DO NOT EDIT.

// 追加でメソッドを定義したい場合は、別ファイルでメソッドを追加してください。
// 別ファイルはこのファイルから、「_gen」を取った名前で作成してください。
// 例:hogehoge_gen.go -> hogehoge.go

package enum

import (
    "fmt"
	"sort"
	"strconv"
)

type {{.ClassPascal}}Enum string

var {{.ClassPascal}} = struct {
{{- range $enumMember := .EnumMembers}}
	{{$enumMember.NamePascal}} {{$.ClassPascal}}Enum
{{- end}}
	opts map[string]map[string]interface{}
}{
{{- range $enumMember := .EnumMembers}}
	{{$enumMember.NamePascal}}: {{$.ClassPascal}}Enum("{{$enumMember.Value}}"),
{{- end}}
	opts: map[string]map[string]interface{}{
{{- range $enumMember := .EnumMembers}}
		"{{$enumMember.Value}}": {
	{{- range $enumOpt := $enumMember.EnumOpts}}
		{{- if $enumOpt.IsBoolValue }}
			"{{$enumOpt.Key}}": {{$enumOpt.Value}},
		{{- else}}
			"{{$enumOpt.Key}}": "{{$enumOpt.Value}}",
		{{- end}}
	{{- end}}
		},
{{- end}}
	},
}

// To{{.ClassPascal}} は引数に該当する{{.ClassPascal}}Enumを返却する。
// 該当する{{.ClassPascal}}がない場合、nilを返す。
func To{{.ClassPascal}}(val string) (*{{$.ClassPascal}}Enum, error) {
	switch val {
{{- range $enumMember := .EnumMembers}}
	case {{$.ClassPascal}}.{{$enumMember.NamePascal}}.String():
		return &{{$.ClassPascal}}.{{$enumMember.NamePascal}}, nil
{{- end}}
	}
	return nil, fmt.Errorf("{{.ClassPascal}}Enum への変換に失敗しました value: %v", val)
}

// All{{.ClassPascal}}Values は {{.ClassPascal}}Enum の一覧を返す。
func All{{.ClassPascal}}Values() []{{.ClassPascal}}Enum {
	values := []{{.ClassPascal}}Enum{
    {{- range $enumMember := .EnumMembers}}
            {{$.ClassPascal}}.{{$enumMember.NamePascal}},
    {{- end}}
	}

	sort.Slice(values, func(i, j int) bool {
	    ordinalI, _ := strconv.Atoi(values[i].Ordinal())
	    ordinalJ, _ := strconv.Atoi(values[j].Ordinal())
	    return ordinalI < ordinalJ
	})

	return values
}

// Equals は引数と同じ{{.ClassPascal}}Enumであるかを判断する。
// 同じ場合にtrueを返す。
func (e *{{.ClassPascal}}Enum) Equals(obj {{.ClassPascal}}Enum) bool{
	return *e == obj
}

// In は引数の中に同一の{{.ClassPascal}}Enumがあればtrueを返す。
func (e *{{.ClassPascal}}Enum) In(objs ...{{.ClassPascal}}Enum) bool{
	for _, obj := range objs {
		if e.Equals(obj) {
			return true
		}
	}

	return false
}

// String は{{.ClassPascal}}のValueを返す。
func (e *{{.ClassPascal}}Enum) String() string{
	return string(*e)
}

{{- range $opt := .EnumMembers 0}}
// {{$opt.KeyPascal}} は{{$.ClassPascal}}Enumの{{$opt.Key}}の属性を返す。
	{{- if $opt.IsBoolValue }}
func (e *{{$.ClassPascal}}Enum) {{$opt.KeyPascal}}() bool{
	return {{$.ClassPascal}}.opts[e.String()]["{{$opt.Key}}"].(bool)
}
	{{- else}}
func (e *{{$.ClassPascal}}Enum) {{$opt.KeyPascal}}() string{
	return {{$.ClassPascal}}.opts[e.String()]["{{$opt.Key}}"].(string)
}
	{{- end}}

{{- end}}
enum.tmplに渡すパラメータ&コード生成の構造体(EnumGenerator)

enum.tmplをもとにコード生成するgeneratorのコードを記載します。

enum_generator.go
//go:embed template/enum.tmpl
var enumTemplate string

type EnumGenerator struct {
	enumMappers []*enumMapper
}

type enumMapper struct {
	// ClassSnake はEnum名をスネークケースにしたもの。
	ClassSnake string
	// ClassCamel はEnum名をキャメルケースにしたもの。
	ClassCamel string
	// ClassCamel はEnum名をパスカルケースにしたもの。
	ClassPascal string
	// EnumMembers はEnumの値の属性情報。
	EnumMembers []enumMember
}

type enumMember struct {
	// NamePascal はEnumの値の識別子(パスカルケース)。
	NamePascal string
	// NamePascal はEnumの値。
	Value    string
	// EnumOpts はEnumの値の属性情報。
	EnumOpts []enumOpt
}

type enumOpt struct {
	// Key はEnumの値の属性のキー。
	Key         string
	// Value はEnumの値の属性の値。
	Value       string
	// KeyPascal はEnumの値の属性のキー(パスカルケース)。
	KeyPascal   string
	// IsBoolValue はEnumの値の属性の値がbool型ならtrue, そうでなければstring型として扱う。
	IsBoolValue bool
}

// Generate はEnumを生成する。
// outputPath は出力先のフォルダ。
func (g *EnumGenerator) Generate(outputPath string) error {
	tmpl, err := template.New("go").Parse(enumTemplate)
	for _, entity := range g.enumMappers {
		if err != nil {
			return err
		}
		f, err := os.Create(path.Join(outputPath, entity.ClassSnake+"_gen.go"))
		if err != nil {
			return err
		}
		if err := tmpl.Execute(f, entity); err != nil {
			return err
		}
		f.Close()
	}

	return gofmt(outputPath)
}

func gofmt(path string) error {
	if err := exec.Command("gofmt", "-w", path).Run(); err != nil {
		return err
	}

	return exec.Command("goimports", "-w", path).Run()
}
データイメージ.json
{
  "ClassSnake": "order_status",
  "ClassCamel": "orderStatus",
  "ClassPascal": "OrderStatus",
  "EnumMembers": [
    {
      "NamePascal": "Requested",
      "Value": "requested",
      "EnumOpts": [
        {
          "Key": "ja",
          "Value": "",
          "KeyPascal": "Ja",
          "IsBoolValue": false
        },
        {
          "Key": "ordinal",
          "Value": "0",
          "KeyPascal": "Ordinal",
          "IsBoolValue": false
        },
        {
          "Key": "is_cancelable",
          "Value": "true",
          "KeyPascal": "IsCancelable",
          "IsBoolValue": true
        }
      ]
    },
    ...
  ]
}

おわりに

Enumのコード量は決して少なくないのですが、そこをコード生成を作り切れば、あとは結構楽に扱える形になっていると思います。気に入った方はぜひ試してみていただければ嬉しいです。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1