正小歪 BLOG

「译」在 Golang 中实现枚举类型

原文地址 在这篇文章中,我们将介绍使用 go generate 和 abstract 语法树遍历生成强大的枚举类型。

这篇文章描述用于生成的 CLI,完全的原代码 可以在 Github 上找到。

Go 中惯用法

Go 语言实际上没有对枚举类型提供完成的支持。定义枚举类型的其中一种方法就是把一类相关变量定义成一种类型。Iota 可以用于定义连续的递增的整数常量。我们可以像这样定义一个 Color 类型。

https://play.golang.org/p/1Zib29yiuFy

package main

import "fmt"

type Color int

const (
    Red Color = iota // 0
    Blue             // 1
)

func main() {
    var b1 Color = Red
    b1 = Red
    fmt.Println(b1) // prints 0

    var b2 Color = 1
    fmt.Println(b2 == Blue) // prints true

    var b3 Color
    b3 = 42
    fmt.Println(b3)  // prints 42
}

这种写法在 Go 代码中很常见,虽然这种方法很常用,但是有一些缺点。因为任何整数都可以给 Color 赋值,所以无法进行使用静态检查。

  • 缺乏序列化——虽然这个不经常使用(开发者想要序列化这个整数,用于传参或者记录到数据库)
  • 缺乏可读性的值——我们需要将 const 值转化成代码中显示的值

了解一种语言的习惯用法以及何时该打破这种习惯很重要。习惯用法往往会限制我们的 “视野”,这有时候恰恰是缺乏创造力的原因。

设计枚举类型

简洁是 Go 语言最重要的特性之一,其他语言的开发者可以很快上手。从另一方面看,可能会产生约束,比如缺乏泛型机制导致许多重复的代码。为了克服这些缺点,社区已经使用代码生成作为定义更强大和灵活类型的机制。

我们就使用这种方法来定义枚举类型,这种方法是使用生成的枚举作为 struct。我们还可以添加方法到 struct 中,struct 还支持 tag,这对于定义显示值和描述很有用。

type ColorEnum struct {
    Red  string `enum:"RED"`
    Blue string `enum:"BLUE"`
}

现在我们需要做的是给每个字段生成结构的实例。

var Red  = Color{name: "RED"}
var Blue = Color{name: "BLUE"}

添加方法到 Color struct 支持 JSON 编码/解码,我们实现 Marshaler 的 interface 支持 JSON 的编码。

func (c Color) MarshalJSON() ([]byte, error) {
    return json.Marshal(c.name)
}

在这个类型序列化时候将会调用我们的自定义实现。同样我们可以实现 Unmarshaler 的 interface,这将让我们可以在代码中使用类型——这允许我们直接在 API 的数据传输对象上定义枚举。

func (c *Color) UnmarshalJSON(b []byte) error {
    return json.Unmarshal(b, c.name)
}

我们还可以定义一些辅助的方法来生成显示的值。

// ColorNames returns the displays values of all enum instances as a slice
func ColorNames() []string { ... }

我们也希望从字符串生成枚举实例,所以添加还需要添加这个方法。

// NewColor generates a new Color from the given display value (name)
func NewColor(value string) (Color, error) { ... }

这些行为都是可扩展的,你可以添加其他方法来返回名字,通过显示 Error() string 来支持错误,并且通过 String() string 来支持 Stringer。

生成代码

遍历抽象语法树

在渲染模板之前,我们需要在源码中解析出 ColorEnum 类型。两种常用的方法是使用 reflet 包和 ast 包。我们需要扫描包级别的 struct。ast 包具有生成抽象语法树的能力——一种可表示 Go 源码的可遍历数据结构。我们可以遍历语法树并且匹配提供的类型,然后可以解析类型和定义的 struct tag,并用在构建模型已生成模板。我们先加载一个 go 包。

cfg := &packages.Config{
    Mode:  packages.LoadSyntax,
    Tests: false,
}
pkgs, err := packages.Load(cfg, patterns...)

pkgs 变量中包含每个文件的语法树。使用 ast.Inspect 方法来遍历 AST。它需要为每个遇到的节点调用一个函数,我们以此遍历每个文件并且处理该文件的语法树。

for _, file := range pkg.files {
...
    ast.Inspect(file.file, func(node ast.Node) bool {
        // ...handle node, check if it's something we are interested in
    })
}

使用者应该定义这个函数,然后按照感兴趣的 token 类型进行过滤。你可以通过节点上的此检查来过滤。

node.Tok == token.STRUCT { ... }

在我们的例子中,通过定义了 “enmu:” 标签的 struct 进行过滤。我们只是处理了源码中每个标记,并根据遇到的数据类型进行模型(自定义 Go struct)的构建。

生成源代码

有许多生成代码的方法。Stringer 工具使用 fmt 包标准输出。虽然这很容易实现,但是随着代码的生成的扩张,这将会变得难以操作和调试。更合理的方式是使用 text/template 包,并且使用 Go 强大的模板库。它允许从模板中分离生成模型的逻辑,从而可以关注点分离和让代码易于推理。生成的类型定义可能如下所示:

// {{.NewType}} is the enum that instances should be created from
type {{.NewType}} struct {
    name  string
}

// Enum instances
{{- range $e := .Fields}}
var {{.Value}} = {{$.NewType}}{name: "{{.Key}}"}
{{- end}}

... code to generate methods

然后我们可以使从模型中渲染出源码

t, err := template.New(tmpl).Parse(tmpl)
if err != nil {
    log.Fatal("instance template parse error: ", err)
}

err = t.Execute(buf, model)

当我们在制作模板时候不要担心格式化的问题。format 包中有一个方法,它将源码作为参数并且返回格式化的 Go 代码,所以应该应该 Go 帮你处理这个问题。

func Source(src []byte) ([]byte, error) { ... }

总结

在这篇中文,我们研究了一种通过解析 Go 源码来生成枚举类型的方法。此方法可以作为模板来构建所需要的源码和作为其他代码的生成器。我们使用 Go 的 text/template 库可以维护的方式呈现源码。

可以在 Github 阅读 完整的代码