Golang中读取和解析/验证配置文件的惯用方法是什么

Golang中读取和解析/验证配置文件的惯用方法是什么 我想使用环境变量来设置配置。这些变量应包含有效的输入,因此我需要验证它们,但更好的做法是解析它们。(解析,而非验证

我正在使用 Viper,并以下列代码开始:

package configuration

import "github.com/spf13/viper"

func init() {
	viper.SetEnvPrefix("MY_APP")
	viper.AutomaticEnv()
}

以一个日志配置为例:

package logging

type Configuration struct {
	Level int `mapstructure:"LOGGING_LEVEL"`
}

我使用一个函数来读取配置:

package logging

import "github.com/spf13/viper"

func GetConfiguration() (Configuration, error) {
	var configuration Configuration

	err := viper.Unmarshal(&configuration)

	return configuration, err
}

以及一个函数来验证它:

package logging

func ValidateConfiguration(configuration Configuration) error {
	// validate configuration here

	return nil
}

但有几件事我不太满意:

  • 我认为更好的做法是解析这些变量
  • 我该如何设置有用的回退值(/默认值)

我想我是在寻找一种描述配置模式并据此进行解析的方法。你们是如何处理配置的?


更多关于Golang中读取和解析/验证配置文件的惯用方法是什么的实战教程也可以访问 https://www.itying.com/category-94-b0.html

5 回复

很抱歉进行这种无耻的自我推销,但我写了一篇关于这个问题的博客文章 🙂

A Declarative Config for Golang

简而言之,我认为目前没有一种很好的惯用方法来解决这个问题。我见过的最好方法是 kubebuilder 在结构体之上使用代码生成的方式。具体来说,他们创建了一个称为“标记”的概念,这类似于标签,但用于构建时。

这是 kubebuilder 书籍 中关于它们样子的链接:

// CronJobSpec 定义了 CronJob 的期望状态
type CronJobSpec struct {
    //+kubebuilder:validation:MinLength=0

    // Cron 格式的调度计划,参见 https://en.wikipedia.org/wiki/Cron。
    Schedule string `json:"schedule"`

    //+kubebuilder:validation:Minimum=0

    // 如果作业因任何原因错过计划时间,启动作业的可选截止时间(秒)。
    // 错过的作业执行将被计为失败。
    // +optional
    StartingDeadlineSeconds *int64 `json:"startingDeadlineSeconds,omitempty"`

更多关于Golang中读取和解析/验证配置文件的惯用方法是什么的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


这看起来相当冗长。你似乎是希望在结构体中声明式地定义验证逻辑,而不仅仅是编写代码来验证它们。是这样吗?如果我要做类似的事情,我想我可能会导入某种验证模块,或者快速自己实现一个。对于"导入现有模块"的版本,也许可以看看这个?

GitHub - go-playground/validator

我认为大多数Go开发者会保持简单,编写类似这样的代码:

type Configuration struct {
	Level int8 `mapstructure:"LOGGING_LEVEL"`
}

func (c Configuration) Validate() error {
	minimumLogLevel := int8(zerolog.TraceLevel)
	if c.Level < minimumLogLevel {
		return fmt.Errorf("Log level is too low. Actual log level is %d, but the minimum log level is %d.", c.Level, minimumLogLevel)
	}
	maximumLogLevel := int8(zerolog.PanicLevel)
	if c.Level > maximumLogLevel {
		return fmt.Errorf("Log level is too high. Actual log level is %d, but the maximum log level is %d.", c.Level, maximumLogLevel)
	}
	return nil
}

func GetConfiguration() (Configuration, error) {
	viper.SetDefault("LOGGING_LEVEL", int8(zerolog.InfoLevel))
	var configuration Configuration
	err := viper.Unmarshal(&configuration)
	return configuration, err
}

……然后编写单元测试,以确保它的行为符合你的期望,并且将来也能继续如此。快速的编译时间意味着运行单元测试的成本很低。这段代码非常易于阅读和理解。

感谢您的回复。我尝试按照您的建议修改了代码,您能帮忙看一下吗?作为一个Go语言初学者,我需要适应地道的写法……

(实际代码被拆分到多个文件中,这里只是一个示例)

// using fmt, zerolog, viper

type LogLevelTooLowError struct {
	ActualLogLevel  int8
	MinimumLogLevel int8
}

func (logLevelTooLowError LogLevelTooLowError) Error() string {
	return fmt.Sprintf("Log level is too low. Actual log level is %d, but the minimum log level is %d.", logLevelTooLowError.ActualLogLevel, logLevelTooLowError.MinimumLogLevel)
}

// ##########################################################

type LogLevelTooHighError struct {
	ActualLogLevel  int8
	MaximumLogLevel int8
}

func (logLevelTooHighError LogLevelTooHighError) Error() string {
	return fmt.Sprintf("Log level is too high. Actual log level is %d, but the maximum log level is %d.", logLevelTooHighError.ActualLogLevel, logLevelTooHighError.MaximumLogLevel)
}

// ##########################################################

type Configuration struct {
	Level int8 `mapstructure:"LOGGING_LEVEL"`
}

// ##########################################################

func GetConfiguration() (Configuration, error) {
	viper.SetDefault("LOGGING_LEVEL", int8(zerolog.WarnLevel))

	var configuration Configuration

	err := viper.Unmarshal(&configuration)

	return configuration, err
}

// ##########################################################

func ValidateConfiguration(configuration Configuration) error {
	minimumLogLevel := int8(zerolog.TraceLevel)

	if configuration.Level < minimumLogLevel {
		return LogLevelTooLowError{
			MinimumLogLevel: minimumLogLevel,
			ActualLogLevel:  configuration.Level,
		}
	}

	maximumLogLevel := int8(zerolog.PanicLevel)

	if configuration.Level > maximumLogLevel {
		return LogLevelTooHighError{
			MaximumLogLevel: maximumLogLevel,
			ActualLogLevel:  configuration.Level,
		}
	}

	return nil
}

有什么建议吗?

我刚刚读了那篇文章,个人觉得它没什么价值。它主要是在争论语义问题。而且在我看来,它争论的语义是不正确的。

这两个函数几乎完全相同:它们检查提供的列表是否为空,如果为空,则用错误消息中止程序。区别完全在于返回类型:validateNonEmpty 总是返回 (),即不包含任何信息的类型,但 parseNonEmpty 返回 NonEmpty a,这是输入类型的一个细化,保留了在类型系统中获得的知识。这两个函数检查的是同一件事,但 parseNonEmpty 让调用者能够访问它学到的信息,而 validateNonEmpty 只是将其丢弃。

你无法“解析”某个东西不为空,但你可以“验证”它不为空或“断言”它不为空。你无法“解析”某个东西看起来像有效的电子邮件地址,但你可以“验证”/“核实”/“断言”它看起来像有效的电子邮件地址。同样地,仅仅因为某个东西被称为“验证”,并不意味着它不能同时返回信息和沿途发现的任何错误。

但有几件事我不喜欢

  • 我认为最好解析变量

你为什么这么认为?你能在你的具体案例中给我一个例子,说明“解析”比“验证”更可取吗?在你的具体情况下,你试图避免哪些类型的错误/问题?

  • 我如何设置有用的回退值(/默认值)

我对 Viper 没有太多经验,但你可以设置默认值

我想我正在寻找一种方法来描述配置模式并据此进行解析。你如何处理你的配置?

从高层次上讲,这取决于我的目标。对于云/容器化应用,我更喜欢环境变量。对于更接近硬件/没有容器的本地开发,我更喜欢配置文件(我默认使用 JSON,因为它非常普遍,但许多 Go 开发者出于某种原因喜欢 toml)。但无论我的配置来自哪里,我处理它的方式都是一样的:

  1. 尝试读取配置。这包括诸如在环境变量未设置时回退到配置文件等操作。这包括诸如解析字符串以确保它们是目标所需的任何数据类型(在我看来,这与验证它们不同)。
  2. 尝试验证配置,使其达到我基本满意的状态,即应用程序至少机会运行(例如,如果我需要一个 SQL 连接字符串而它是空的,我知道应用程序无法运行;如果日志级别未设置,我可以直接使用某个默认值)。
  3. 如果我知道应用程序无法运行,就向控制台 log.Fatalf 一些有用的信息。这样做的目的是,当 Google Cloud Run 无法启动时,你希望可以检查日志,看到你忘记使用密钥管理器来设置 APP_CONNECTION_STRING 环境变量之类的信息。再次强调,尽量提供有用/具体的信息。

老实说,我通常只使用标准库。如果你真的想,你可以用结构体标签来定义配置的元数据:

type appConfig struct {
	LogLevel int    `json:"logLevel" env:"MY_APP_LOG_LEVEL" default:"0" desc:"Can be set to 0, 1, 2."`
	Env      string `json:"env" env:"MY_APP_ENVIRONMENT" default:"Dev" desc:"Dev, Test, or Production."`
}

… 然后使用反射来读取它们:

func main() {
	conf := appConfig{}
	printStructTags(reflect.ValueOf(conf))
}

func printStructTags(f reflect.Value) {
	// Iterate over our fields and grab tags
	for i := 0; i < f.NumField(); i++ {
		field := f.Type().Field(i)
		tag := field.Tag
		fmt.Printf("Field: %v.\n\tExpected JSON tag: %v.\n\tEnvironment variable: %v.\n\tDefault: %v.\n\tDescription: %v.\n", field.Name, tag.Get("json"), tag.Get("env"), tag.Get("default"), tag.Get("desc"))
	}
}

这会打印出

Field: LogLevel.
	Expected JSON tag: logLevel.
	Environment variable: MY_APP_LOG_LEVEL.
	Default: 0.
	Description: Can be set to 0, 1, 2..
Field: Env.
	Expected JSON tag: env.
	Environment variable: MY_APP_ENVIRONMENT.
	Default: Dev.
	Description: Dev, Test, or Production..

目前,我建议你坚持使用 Viper 的示例。Viper 已经在许多生产项目中成功使用,包括 Hugo。我敢打赌它能很好地满足你的需求。如果不能,配置是一个相对简单的主题,你可以直接使用标准库。如前所述,你可以使用结构体标签来构建自己的配置对象,并将元数据嵌入结构体本身。

在Go中处理配置解析和验证的惯用方法是采用“解析而非验证”的模式。以下是一个结合Viper的实现示例,包含类型安全的解析、默认值设置和错误处理:

package logging

import (
    "fmt"
    "strconv"
    "strings"

    "github.com/spf13/viper"
)

type LogLevel int

const (
    LevelDebug LogLevel = iota
    LevelInfo
    LevelWarn
    LevelError
)

type Configuration struct {
    Level LogLevel `mapstructure:"LOGGING_LEVEL"`
}

// 解析函数,将配置转换为类型安全的结构
func ParseConfiguration() (Configuration, error) {
    var config Configuration
    
    // 设置默认值
    viper.SetDefault("LOGGING_LEVEL", "info")
    
    // 获取原始值并解析
    levelStr := viper.GetString("LOGGING_LEVEL")
    level, err := parseLogLevel(levelStr)
    if err != nil {
        return Configuration{}, fmt.Errorf("invalid log level: %w", err)
    }
    
    config.Level = level
    return config, nil
}

// 类型安全的解析函数
func parseLogLevel(s string) (LogLevel, error) {
    switch strings.ToLower(s) {
    case "debug", "0":
        return LevelDebug, nil
    case "info", "1":
        return LevelInfo, nil
    case "warn", "warning", "2":
        return LevelWarn, nil
    case "error", "err", "3":
        return LevelError, nil
    default:
        // 尝试解析为数字
        if i, err := strconv.Atoi(s); err == nil {
            if i >= 0 && i <= 3 {
                return LogLevel(i), nil
            }
        }
        return LevelInfo, fmt.Errorf("invalid log level: %s", s)
    }
}

// 使用示例
func init() {
    viper.SetEnvPrefix("MY_APP")
    viper.AutomaticEnv()
}

func GetConfiguration() (Configuration, error) {
    return ParseConfiguration()
}

对于更复杂的配置结构,可以使用工厂模式:

package config

import (
    "time"
    
    "github.com/spf13/viper"
)

type DatabaseConfig struct {
    Host     string
    Port     int
    Username string
    Password string
    Timeout  time.Duration
}

type ServerConfig struct {
    Addr         string
    ReadTimeout  time.Duration
    WriteTimeout time.Duration
}

type Config struct {
    Database DatabaseConfig
    Server   ServerConfig
    LogLevel string
}

func ParseConfig() (*Config, error) {
    // 设置默认值
    viper.SetDefault("SERVER_ADDR", ":8080")
    viper.SetDefault("SERVER_READ_TIMEOUT", "30s")
    viper.SetDefault("SERVER_WRITE_TIMEOUT", "30s")
    viper.SetDefault("DB_HOST", "localhost")
    viper.SetDefault("DB_PORT", 5432)
    viper.SetDefault("DB_TIMEOUT", "10s")
    viper.SetDefault("LOG_LEVEL", "info")
    
    // 解析持续时间
    readTimeout, err := time.ParseDuration(viper.GetString("SERVER_READ_TIMEOUT"))
    if err != nil {
        return nil, fmt.Errorf("invalid read timeout: %w", err)
    }
    
    writeTimeout, err := time.ParseDuration(viper.GetString("SERVER_WRITE_TIMEOUT"))
    if err != nil {
        return nil, fmt.Errorf("invalid write timeout: %w", err)
    }
    
    dbTimeout, err := time.ParseDuration(viper.GetString("DB_TIMEOUT"))
    if err != nil {
        return nil, fmt.Errorf("invalid database timeout: %w", err)
    }
    
    // 验证端口范围
    dbPort := viper.GetInt("DB_PORT")
    if dbPort < 1 || dbPort > 65535 {
        return nil, fmt.Errorf("invalid database port: %d", dbPort)
    }
    
    return &Config{
        Database: DatabaseConfig{
            Host:     viper.GetString("DB_HOST"),
            Port:     dbPort,
            Username: viper.GetString("DB_USERNAME"),
            Password: viper.GetString("DB_PASSWORD"),
            Timeout:  dbTimeout,
        },
        Server: ServerConfig{
            Addr:         viper.GetString("SERVER_ADDR"),
            ReadTimeout:  readTimeout,
            WriteTimeout: writeTimeout,
        },
        LogLevel: viper.GetString("LOG_LEVEL"),
    }, nil
}

对于需要严格验证的配置,可以使用自定义类型和解析方法:

package config

type Port int

func (p *Port) UnmarshalText(text []byte) error {
    port, err := strconv.Atoi(string(text))
    if err != nil {
        return fmt.Errorf("invalid port: %w", err)
    }
    if port < 1 || port > 65535 {
        return fmt.Errorf("port out of range: %d", port)
    }
    *p = Port(port)
    return nil
}

type Duration time.Duration

func (d *Duration) UnmarshalText(text []byte) error {
    dur, err := time.ParseDuration(string(text))
    if err != nil {
        return fmt.Errorf("invalid duration: %w", err)
    }
    *d = Duration(dur)
    return nil
}

type StrictConfig struct {
    Port     Port     `mapstructure:"PORT"`
    Timeout  Duration `mapstructure:"TIMEOUT"`
}

func LoadStrictConfig() (StrictConfig, error) {
    viper.SetDefault("PORT", "8080")
    viper.SetDefault("TIMEOUT", "30s")
    
    var config StrictConfig
    if err := viper.Unmarshal(&config); err != nil {
        return StrictConfig{}, fmt.Errorf("failed to unmarshal config: %w", err)
    }
    
    return config, nil
}

这些示例展示了如何在解析阶段进行类型转换和验证,确保配置值的有效性,同时提供有意义的默认值。

回到顶部