Safer Enums in Go

警告
本文最后更新于 2021-12-11,文中内容可能已过时。

此文为 Safer Enums in Go 译文

枚举是 Web 应用程序的重要组成部分。Go 并没有开箱即用地支持它们,但有一些方法可以模拟它们。

许多显而易见的解决方案远非理想。以下是我们使用的一些想法,它们通过设计使枚举更安全。

Go 中可以使用 iota 枚举。

1
2
3
4
5
6
const (
	Guest = iota
	Member
	Moderator
	Admin
)

iota 适用于由 2 的幂表示的标志。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const (
	Guest = 1 << iota // 1
	Member            // 2
	Moderator         // 4
	Admin             // 8
)

// ...

user.Roles = Member | Moderator // 6

位掩码很有效,有时也很有帮助。但是,它与大多数 Web 应用程序中的枚举不同。通常,您可以将所有角色存储在列表中。它也将更具可读性。

iota的主要问题是它在整数上工作,没有防止传递无效的值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func CreateUser(role int) error {
	fmt.Println("Creating user with role", role)
	return nil
}

func main() {
	err := CreateUser(-1)
	if err != nil {
		fmt.Println(err)
	}
	
	err = CreateUser(42)
	if err != nil {
		fmt.Println(err)
	}
}

即使没有相应的角色,CreateUser 也会很乐意接受 -1 或 42。

当然,我们可以在函数中验证这一点。但是我们使用具有强类型的语言,所以让我们利用它。在我们的应用程序上下文中,用户角色不仅仅是一个模糊的数字。

反模式:整数枚举

不要使用基于 iota 的整数来表示不是连续数字或标志的枚举。

我们可以引入一种类型来改进解决方案。

1
2
3
4
5
6
7
8
type Role uint

const (
	Guest Role = iota
	Member
	Moderator
	Admin
)

它看起来更好,但仍然可以传递任意整数来代替 Role。 Go 编译器在这里没有帮助我们。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func CreateUser(role Role) error {
	fmt.Println("Creating user with role", role)
	return nil
}

func main () {
	err := CreateUser(0)
	if err != nil {
		fmt.Println(err)
	}
	
    err = CreateUser(role.Role(42))
    if err != nil {
        fmt.Println(err)
    }
}

该类型是对裸整数的改进,但它仍然是一种错觉。它不给我们任何保证该角色是有效的。

因为 iota 从 0 开始,Guest 也是 Role 的零值。这使得很难检测角色是否为空或有人传递了 Guest 值。

您可以通过从 1 开始计数来避免这种情况。更好的是,保留一个可以比较且不会误认为实际角色的明确标记值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
const (
	Unknown Role = iota
	Guest
	Member
	Moderator
	Admin
)

func CreateUser(r role.Role) error {
	if r == role.Unknown {
		return errors.New("no role provided")
	}
	
	fmt.Println("Creating user with role", r)
	
	return nil
}

最佳实践:

为枚举的零值保留一个显式变量。

枚举似乎是关于连续整数,但它很少是有效的表示。在 Web 应用程序中,我们使用枚举来对某种类型的可能变体进行分组。它们不能很好地映射到数字。

当您在 API 响应、数据库表或日志中看到 3 时,很难理解上下文。您必须检查来源或过时的文档以了解其内容。

在大多数情况下,字符串 slug 比整数更有意义。无论你在哪里看到它,主持人都很明显。由于 iota 无论如何都无济于事,我们也可以使用人类可读的字符串。

1
2
3
4
5
6
7
8
9
type Role string

const (
	Unknown   Role = ""
	Guest     Role = "guest"
	Member    Role = "member"
	Moderator Role = "moderator"
	Admin     Role = "admin"
)

最佳实践:Slugs

使用字符串值而不是整数。 避免使用空格以便于解析和记录。使用camelCase、snake_case 或kebab-case。

Slug 对错误代码特别有用。与 {“error”: 4102} 相比,像 {“error”: “user-not-found”} 这样的错误响应是显而易见的。

但是,该类型仍然可以包含任意字符串。

1
2
3
4
err = CreateUser("super-admin")
if err != nil {
	fmt.Println(err)
}

最后的迭代使用结构。它让我们可以使用设计安全的代码。我们不需要检查传递的值是否正确。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
type Role struct {
	slug string
}

func (r Role) String() string {
	return r.slug
}

var (
	Unknown   = Role{""}
	Guest     = Role{"guest"}
	Member    = Role{"member"}
	Moderator = Role{"moderator"}
	Admin     = Role{"admin"}
)

由于 slug 字段未导出,因此无法从包外部填充它。您可以构建的唯一无效角色是空角色:Role{}。

我们可以添加一个构造函数来创建一个基于 slug 的有效角色:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func FromString(s string) (Role, error) {
	switch s {
	case Guest.slug:
		return Guest, nil
	case Member.slug:
		return Member, nil
	case Moderator.slug:
		return Moderator, nil
	case Admin.slug:
		return Admin, nil
	}

	return Unknown, errors.New("unknown role: " + s)
}

最佳实践:

基于结构的枚举 将枚举封装在结构中以获得额外的编译时安全性

这种方法在你处理业务逻辑时是完美的。保持结构在内存中始终处于有效状态,使你的代码更容易操作和理解。检查枚举类型是否为空就足够了,而且你可以确定它是一个正确的值。

这种方法存在一个潜在问题。 Go 中的结构不能是常量,因此可以像这样覆盖全局变量:

1
roles.Guest = role.Admin

但是,没有合理的理由这样做。你更有可能意外地传递一个无效的整数。

另一个缺点是您必须在两个地方进行更新:枚举列表和构造函数。然而,它很容易被发现,即使你一开始错过了它。

相关内容