在Go语言中实现枚举覆盖性自动检查

在业务系统中,我们经常有依据枚举值来执行不同逻辑的需求,并且同一个枚举类型往往在系统中会有很多处都在使用。当枚举值增加时,每一个用到枚举值以执行不同逻辑的地方都需要判断应该如何对这个新增的枚举值做处理。

在Go语言中实现枚举覆盖性自动检查
Photo by Marek Piwnicki / Unsplash

问题背景

在业务系统中,我们经常有依据枚举值来执行不同逻辑的需求,并且同一个枚举类型往往在系统中会有很多处都在使用。当枚举值增加时,每一个用到枚举值以执行不同逻辑的地方都需要判断应该如何对这个新增的枚举值做处理。

面临的挑战

  1. 手动维护易出错:代码中不同的地方散落了相关的判断逻辑。每次添加新的枚举值时,需要手动更新相应的逻辑,容易遗漏
  2. 覆盖性难以验证:无法检查业务逻辑中是否覆盖了所有必要的枚举值。也不可能通过手动编写测试来检查是否覆盖了必要的枚举值,因为测试用例的编写本身就需要effort,容易被遗忘,我们需要更自动化的机制
  3. 运行时发现问题:错误往往在运行时才被发现,而不是编译时

类似的“枚举值需要在每个使用的地方进行穷尽性检查”是一个比较普遍的需求,值得研究通用的解决方案。

问题分析

原有的枚举定义

enum ProblemCategory {
    None = 0
    Aaa = 1
    Bbb = 2
    Ccc = 3
    Ddd = 4
}

原有的业务逻辑

// 修改前的代码
var problemCategoriesType1 = []ProblemCategory{
    ProblemCategory_None,
    ProblemCategory_Aaa,
    ProblemCategory_Bbb,
}

var problemCategoriesType2 = []ProblemCategory{
    ProblemCategory_None,
    ProblemCategory_Ccc,
    ProblemCategory_Ddd,
}


func MyFunction(ctx context.Context, req *Req) error {
      if !gslice.Contains(problemCategoriesType1, req.GetProblemCategory()) {
          // business logic here
      }
    // ...
}

引申:Rust如何在语言级别解决这个问题

Rust中的match表达式有穷尽性检查(exhaustiveness checking):

enum Color {
    Red,
    Green,
    Blue,
}
// 编译器会强制你处理所有枚举值
match color {
    Color::Red => println!("red"),
    Color::Green => println!("green"),
    // 如果没有Blue分支,编译器会报错
}

go中没有这样的编译检查,需要通过其他方式来实现。

解决方案:使用 Exhaustive Linter

Exhaustive 是一个 Golangci-lint 中的 linter,用于检查 switch 语句和 map 中是否覆盖了枚举类型的所有可能值。我们采用 分类函数 + exhaustive linter 的方案:

  1. 创建分类函数:使用 switch 语句对所有枚举值进行分类
  2. 配置 exhaustive linter:确保 switch 语句覆盖所有枚举值
  3. 提供辅助函数:封装业务逻辑,处理特殊情况

实现过程

第一步:定义分类类型和函数

// ProblemCategoryType 表示问题类别的分类
type ProblemCategoryType int

const (
    Category1       ProblemCategoryType = 1
    Category2       ProblemCategoryType = 2
    SpecialCategory ProblemCategoryType = 3
)

// ClassifyProblemCategory 对 ProblemCategory 进行分类
func ClassifyProblemCategory(category ProblemCategory) ProblemCategoryType {
    switch category {
    case ProblemCategory_Aaa,
        ProblemCategory_Bbb:
        return Category1

    case ProblemCategory_Ccc,
        ProblemCategory_Ddd:
        return Category2
    default:
        // 这里不应该到达,所有枚举值都应该被处理
        return SpecialCategory
    }
}

第二步:实现辅助函数

func IsCategory1(category ProblemCategory) bool {
    if category == ProblemCategory_None {
        return true
    }
    return ClassifyProblemCategory(category) == Category1
}


func IsCategory2(category ProblemCategory) bool {
    if category == ProblemCategory_None {
        return true
    }
    return ClassifyProblemCategory(category) == Category2
}

第三步:更新业务逻辑

// 修改后的业务逻辑
func SetReviewStatus(ctx context.Context, req *Req) error {
    if IsCategory1(req.GetProblemCategory()) {
        // business logic here
    } else if IsCategory2(req.GetProblemCategory()) {
        // business logic here
    }
      
    
    // ...
}

第四步:配置 golangci-lint

# .golangci.yml
run:
  timeout: 5m
  modules-download-mode: readonly

linters-settings:
  exhaustive:
    check:
      - switch
    # 不把 default case 当作穷尽性的满足条件
    default-signifies-exhaustive: false
    # 检查所有 switch,不只是带注释的
    explicit-exhaustive-switch: false
    # 不限制只检查包级别的枚举
    package-scope-only: false

linters:
  enable:
    - exhaustive
  disable-all: false

方案验证

当我们故意从 switch 语句中移除一个枚举值时:

// 故意移除 AdditionalReviewNeeded 来测试
case ProblemCategory_Aaa,
    // ProblemCategory_Bbb, // 故意注释掉
    // ...

运行 linter 会立即报错:

$ golangci-lint run ./service/review.go --enable-only exhaustive
service/review.go:31:2: missing cases in switch of type ProblemCategory: ProblemCategory_Bbb (exhaustive)
        switch category {
        ^

最佳实践建议

  1. 使用以下 Linter 配置
linters-settings:
  exhaustive:
    check:
      - switch
    # 不把 default case 当作穷尽性的满足条件
    default-signifies-exhaustive: false
    # 检查所有 switch,不只是带注释的
    explicit-exhaustive-switch: false
    # 不限制只检查包级别的枚举
    package-scope-only: false

linters:
  enable:
    - exhaustive
  disable-all: false
  1. 可以将 golangci-lint 加入到 CI 流程中,确保每次分支合并前,进行了枚举值穷尽检查

总结

通过引入 exhaustive linter,我们将枚举覆盖性检查从运行时测试提升到编译时检查,显著提升了代码的健壮性和维护性。未来当我们添加新的 ProblemCategory 枚举值时,exhaustive linter 会自动提醒我们更新分类逻辑,确保在所有地方新增的枚举值被正确处理。

参考资料