この記事は Go 言語 Advent Calendar 2023 6日目の記事です。
はじめに
GoでEnumってどう実現するんだという話をSNSでたびたび目撃します。
GoとEnumについてにベストプラクティスは紹介されていますが、今回は私の関わっているチームで自前実装した方式を紹介します。
すでに本番導入している方式で、数年間本番で追加開発/保守運用する中でもうまく機能していると感じています。
実装したもの
こんな感じです。
定義ファイルから以下のコードを生成して使っています。
// 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パッケージ内に全て配置するようにしています。
このEnumを使うとこんなふうにコードが書けます。
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)
}
推しどころ
-
コード生成にプラスして以下のように_genなしのファイルを作成してメソッドを追加することでenum->enum変換機能なども実装できます。例えば FooTypeからBarGroupへのEnum変換も以下のように定義できます。テストで変換定義漏れの検知可能です。
package enum
func (e *FooTypeEnum) ToBarGroup() BarGroupEnum {
bar, err := ToBarGroup(e.BarGroupString)
if err != nil {
panic(err) // ユニットテストでpanicしないことを担保している。
}
return bar
}
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
// 省略
var BarGroup = struct {
Hoge BarGroupEnum
Piyo BarGroupEnum
opts map[string]map[string]interface{}
}{
// 省略
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に値をセットをしています。
コードテンプレートを記載しておきます。
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のコードを記載します。
//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()
}
{
"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のコード量は決して少なくないのですが、そこをコード生成を作り切れば、あとは結構楽に扱える形になっていると思います。気に入った方はぜひ試してみていただければ嬉しいです。