Docs Vault

开发完应用框架之后,就需要在框架中集成更多的应用功能。几乎所有的应用都需要通过配置来管控应用的行为。本节会基于 feature/s02 分支的代码,详细介绍给应用添加配置功能的具体实现步骤。在介绍的过程中,也会详细介绍我在开发过程中的思考及相关知识点。


如何配置 miniblog 应用


在 Go 应用开发中,使用配置最便捷的方式是将应用配置项都定义在一个配置项结构体中。通过将配置项定义在配置项结构体中,不仅方便在程序中使用这些配置项,还可以统一管理这些配置项,降低程序维护的成本。所以,在 miniblog 项目的应用构建模型中,会在 cmd/mb-apiserver/app/options 目录中存放跟配置项相关的代码,例如:创建默认的配置项、配置项校验、配置项绑定命令行选项等。通过目录级别的隔离,可以提高代码的可维护性。


miniblog 项目,通过定义一个 ServerOptions 结构体类型,来定义应用需要的配置项,ServerOptions 结构体定义如下:

// ServerOptions 包含服务器配置选项。
type ServerOptions struct {
    // ServerMode 定义服务器模式:gRPC、Gin HTTP、HTTP Reverse Proxy。
    ServerMode string `json:"server-mode" mapstructure:"server-mode"`
    // JWTKey 定义 JWT 密钥。
    JWTKey string `json:"jwt-key" mapstructure:"jwt-key"`
    // Expiration 定义 JWT Token 的过期时间。
    Expiration time.Duration `json:"expiration" mapstructure:"expiration"`
}

定义应用配置项结构体类型之后,还需要提供一种途径来给配置项结构体中各个字段设置期望的值。在 Go 项目开发中,有三种方式,来给这些配置项设置值:创建默认的配置项、通过命令行选项设置配置项、通过配置文件设置配置项。


创建默认的配置项


可以通过类似 NewServerOptions 这样的函数,来创建具有默认值的配置项结构体变量,例如:

// NewServerOptions 创建带有默认值的 ServerOptions 实例。
func NewServerOptions() *ServerOptions {
    return &ServerOptions{
        ServerMode: "grpc-gateway",
        JWTKey:     "Rtg8BPKNEf2mB4mgvKONGPZZQSaJWNLijxR42qRgq0iBb5",
        Expiration: 2 * time.Hour,
    }
}

设置具有默认值的配置项能够显著提升程序的可用性和稳定性。通过提供合理的初始设置,程序即使在未提供配置或配置文件丢失的情况下仍能正常运行,避免因缺少配置而导致的异常。同时,默认值降低了配置输入的依赖,使程序可以快速启动,尤其适用于开发、调试和测试阶段,可以大幅提高开发效率。


默认值不仅为开发者和运维人员提供了参考,有助于理解和管理配置项,还允许用户通过环境变量、命令行选项或配置文件灵活覆盖默认值。而且,默认值通常反映最常见或推荐的设置,能够作为文档说明的重要补充,从而提升配置的可读性,使应用更易操作和维护。


通过命令行选项设置配置项


除了可以创建带默认值的配置项结构体变量来配置应用外,还可以通过命令行选项来配置应用。使用命令行选项配置应用,可以带来以下好处:

  1. 零成本获知应用程序的配置项:只需要执行 mb-apiserver -h 就能够知道 mb-apiserver 命令有哪些配置项,以及配置项的说明和默认值;
  2. 可快速启动程序:如果应用程序配置项比较少,可以不用编写配置文件,仅通过指定命令行选项即可快速启动程序,例如:_output/mb-apiserver --server-mode=gin;
  3. 维护成本低:不需要维护额外的配置文件,仅限于参数很少的情况下;
  4. 可以通过 Shell 脚本灵活配置应用:可以通过 Shell 脚本灵活指定应用启动时的各项命令行选项,例如_output/mb-apiserver --server-mode=${server_mode}。


在实际的 Go 应用开发中,可以通过将配置项绑定到某个命令行选项,从而通过命令行选项来设置配置项的值,进而在程序中使用这些配置项。例如:

// AddFlags 将 ServerOptions 的选项绑定到命令行标志。
// 通过使用 pflag 包,可以实现从命令行中解析这些选项的功能。
func (o *ServerOptions) AddFlags(fs *pflag.FlagSet) {
    fs.StringVar(&o.ServerMode, "server-mode", o.ServerMode, fmt.Sprintf("Server mode, available options: %v", availableServerModes.UnsortedList()))
    fs.StringVar(&o.JWTKey, "jwt-key", o.JWTKey, "JWT signing key. Must be at least 6 characters long.")
    // 绑定 JWT Token 的过期时间选项到命令行标志。
    // 参数名称为 `--expiration`,默认值为 o.Expiration
    fs.DurationVar(&o.Expiration, "expiration", o.Expiration, "The expiration duration of JWT tokens.")
}

上述代码,将命令行选项 --server-mode、--jwt-key、--expiration 分别绑定到 ServerOptions 结构体中的 ServerMode、JWTKey、Expiration 字段。从而支持通过命令行选项来给 ServerOptions 结构体中绑定的字段设置期望的值,例如:

$ _output/mb-apiserver --expiration=4h


在 Go 应用启动时,可以直接通过命令行选项来设置配置项的值,这种方式最大的优点是便捷。但当配置项太多时,如果都通过命令行选项来设置,会使应用启动参数很长,不便于维护,反而会增加运维复杂度。所以,miniblog 项目仅使用命令行选项来设置核心的配置项,其他配置项通过更易维护的配置文件来设置。例如:

$ _output/mb-apiserver -h
...
Flags:
  -c, --config string         Path to the miniblog configuration file. (default "/home/colin/.miniblog/mb-apiserver.yaml")
      --expiration duration   The expiration duration of JWT tokens. (default 2h0m0s)
  -h, --help                  help for mb-apiserver
      --jwt-key string        JWT signing key. Must be at least 6 characters long. (default "Rtg8BPKNEf2mB4mgvKONGPZZQSaJWNLijxR42qRgq0iBb5")
      --server-mode string    Server mode, available options: [grpc grpc-gateway gin] (default "grpc-gateway")


通过配置文件设置配置项


还可以通过结构化(例如 JSON 格式或 YAML 格式)的配置文件来配置应用。通过结构化的配置文件,开发者可以清晰的了解到应用的所有配置项,从而降低应用配置的维护成本。另外,配置文件也很适合在不同环境下分发。通过配置文件来配置应用,可以带来以下好处:

  1. 配置文件更易部署:可以将应用需要的所有配置,聚合在一个配置文件中,部署时,只需要部署、加载这个配置文件即可启动程序,不用配置一大堆命令行选项;
  2. 配置文件更易维护:可以将应用需要的所有配置都保存在配置文件中,加上详细的配置说明,不需要的配置可以注释掉。一个具有全量配置项、详细说明的配置文件,更易于理解。并且在修改时,只需要修改配置项的值,而不需要修改配置项名称,更不易出错;
  3. 配置文件可以实现热加载能力:应用程序监听配置文件的变更,有变更时重新加载程序配置,可以实现配置热加载能力;
  4. 配置层次表达更清晰:命令行选项无法直接表达“层次”,但配置文件可以。层次化的配置表达,更易于理解。因为具有“层次”,所以配置文件也可以表达更复杂的配置内容。


使用配置文件的方式很简单,只需要给应用添加 --config 命令行选项,在应用启动时,通过 --config 选项指定配置文件,并在程序内将配置文件中的配置项解析到所绑定的配置项结构体字段即可。之后,程序中就可以通过配置项结构体变量来访问这些配置项。整个代码实现逻辑如代码清单 5-4 所示。

代码清单 5-4 配置文件解析示例代码

var configFile string // 配置文件路径

// NewMiniBlogCommand 创建一个 *cobra.Command 对象,用于启动应用程序。
func NewMiniBlogCommand() *cobra.Command {
    // 创建默认的应用命令行选项
    opts := options.NewServerOptions()

    cmd := &cobra.Command{
        // 指定命令的名字,该名字会出现在帮助信息中
        Use: "mb-apiserver",
        ...
        RunE: func(cmd *cobra.Command, args []string) error {
            // 将 viper 中的配置解析到选项 opts 变量中。
            if err := viper.Unmarshal(opts); err != nil {
                return err
            }
            ...
            return nil
        },
        ...
    }

    // 初始化配置函数,在每个命令运行时调用
    cobra.OnInitialize(onInitialize)

    // cobra 支持持久性标志(PersistentFlag),该标志可用于它所分配的命令以及该命令下的每个子命令
    // 推荐使用配置文件来配置应用,便于管理配置项
    cmd.PersistentFlags().StringVarP(&configFile, "config", "c", filePath(), "Path to the miniblog configuration file.")
    ...
    return cmd
}

上述代码,通过 cmd.PersistentFlags()。StringVarP() 调用,给应用添加了 --config/-c 命令行选项,用来指定配置文件的路径,并将配置文件路径保存在 configFile 变量中。configFile 默认值由 filePath() 函数生成,默认值为$HOME/.miniblog/mb-apiserver.yaml。


通过 cobra.OnInitialize(onInitialize) 调用,可以确保程序运行时,会将 --config 命令行选项指定的配置文件内容加载到 viper 中。通过 viper.Unmarshal(opts) 调用,将 viper 中的配置项解析到初始化配置变量 opts 中。


在使用配置文件配置应用的时候,可以根据需要选择多种配置文件格式,例如:JSON、YAML、TOML、INI 等,这里建议使用 YAML,理由如下:

  1. YAML 语法简单、格式易读、程序易处理;
  2. YAML 格式可以表达非常丰富、复杂的配置结构;
  3. YAML 格式普适性高,新人零理解成本。


选择合适的配置方式


通过上面的分析,我建议在配置项少的时候(例如 5 个以内),可以采用命令行选项的方式来配置应用。配置项较多的时候,适合从配置文件读取。考虑到一个应用程序,即使刚开始配置项较少,随着应用的不断的迭代,配置项可能越来越多,使用命令行选项来配置会越来越复杂。所以建议首先创建一个默认的配置,之后分别通过命令行选项和配置文件 2 种方式,来覆盖指定的默认配置项。其中,命令行选项只添加核心、必要的配置,例如 --config。太多命令行选项不易维护。其他应用配置均通过配置文件进行统一配置。


编码实现配置读取功能


上一节,介绍了 miniblog 应用的配置方式。本节,来介绍下具体如何编码实现 miniblog 的配置功能。


创建配置项结构体


miniblog 的所有初始化配置,均聚合定义在配置项结构体类型 ServerOptions 中。为了便于维护这些配置项,我将配置项实现相关的代码统一保存在 cmd/mb-apiserver/app/options 目录中,例如:默认配置项创建、配置项校验等。开发完成后的代码如代码清单 5-5 所示。

代码清单 5-5 配置项代码

package options

import (
    "errors"
    "fmt"
    "time"

    "github.com/spf13/pflag"
    utilerrors "k8s.io/apimachinery/pkg/util/errors"
    "k8s.io/apimachinery/pkg/util/sets"
)

// 定义支持的服务器模式集合。
var availableServerModes = sets.New(
    "grpc",
    "grpc-gateway",
    "gin",
)

// ServerOptions 包含服务器配置选项。
type ServerOptions struct {
    // ServerMode 定义服务器模式:gRPC、Gin HTTP、HTTP Reverse Proxy。
    ServerMode string `json:"server-mode" mapstructure:"server-mode"`
    // JWTKey 定义 JWT 密钥。
    JWTKey string `json:"jwt-key" mapstructure:"jwt-key"`
    // Expiration 定义 JWT Token 的过期时间。
    Expiration time.Duration `json:"expiration" mapstructure:"expiration"`
}

// NewServerOptions 创建带有默认值的 ServerOptions 实例。
func NewServerOptions() *ServerOptions {
    return &ServerOptions{
        ServerMode: "grpc-gateway",
        JWTKey:     "Rtg8BPKNEf2mB4mgvKONGPZZQSaJWNLijxR42qRgq0iBb5",
        Expiration: 2 * time.Hour,
    }
}

// AddFlags 将 ServerOptions 的选项绑定到命令行标志。
// 通过使用 pflag 包,可以实现从命令行中解析这些选项的功能。
func (o *ServerOptions) AddFlags(fs *pflag.FlagSet) {
    fs.StringVar(&o.ServerMode, "server-mode", o.ServerMode, fmt.Sprintf("Server mode, available options: %v", availableServerModes.UnsortedList()))
    fs.StringVar(&o.JWTKey, "jwt-key", o.JWTKey, "JWT signing key. Must be at least 6 characters long.")
    // 绑定 JWT Token 的过期时间选项到命令行标志。
    // 参数名称为 `--expiration`,默认值为 o.Expiration
    fs.DurationVar(&o.Expiration, "expiration", o.Expiration, "The expiration duration of JWT tokens.")

}

// Validate 校验 ServerOptions 中的选项是否合法。
func (o *ServerOptions) Validate() error {
    errs := []error{}

    // 校验 ServerMode 是否有效
    if !availableServerModes.Has(o.ServerMode) {
        errs = append(errs, fmt.Errorf("invalid server mode: must be one of %v", availableServerModes.UnsortedList()))
    }

    // 校验 JWTKey 长度
    if len(o.JWTKey) < 6 {
        errs = append(errs, errors.New("JWTKey must be at least 6 characters long"))
    }

    // 合并所有错误并返回
    return utilerrors.NewAggregate(errs)
}

在代码清单 5-5 中,定义了 ServerOptions 结构体类型,该结构体类型保存了应用程序所有的初始化配置项。通过 ServerOptions 结构体变量,可以方便的在程序中访问其内置的配置项字段。ServerOptions 结构体字段都有 json 和 mapstructure 标签。其中,mapstructure 标签用于将配置文件中的配置项与 Go 结构体字段进行映射,在调用 viper.Unmarshal 函数时,viper 会将配置文件中配置项的值赋值给对应的结构体字段。


AddFlags 方法用来将 ServerOptions 中的字段绑定到命令行标志中。在绑定时,指定了默认值和命令行选项描述。Validate 方法用来验证 ServerOptions 中的配置项字段值是否合法。因为配置项有多个,在校验时,可能有多个配置项不合法,所以代码中,使用了 error 类型的数组来保存这些错误,并通过 utilerrors 包将这些错误聚合为一个错误并返回。utilerrors 是 Kubernetes 生态中的一个包,被大量使用,miniblog 项目这里也是借鉴了 Kubernetes 项目中处理错误的方法。


Kubernetes 项目及其生态中有大量易用的 Go 包,在 Go 项目开发中,可以复用这些 Go 包以提高 Go 代码开发的质量和效率。如果你想提高自己的 Go 开发技能,也建议你多阅读下 Kubernetes 项目的源码及其软件架构方法。


绑定命令行选项


在 cmd/mb-apiserver/app/options/options.go 文件中,定义了 ServerOptions 结构体类型,接下来还需要将 ServerOptions 结构体中的特定字段绑定到 *cobra.Command 实例的全局标志集(FlagSet)中。绑定方法很简单,只需要在 NewMiniBlogCommand 方法中添加以下代码即可:

// NewMiniBlogCommand 创建一个 *cobra.Command 对象,用于启动应用程序。
func NewMiniBlogCommand() *cobra.Command {
    ...
    // cobra 支持持久性标志(PersistentFlag),该标志可用于它所分配的命令以及该命令下的每个子命令
    // 推荐使用配置文件来配置应用,便于管理配置项
    cmd.PersistentFlags().StringVarP(&configFile, "config", "c", filePath(), "Path to the miniblog configuration file.")

    // 将 ServerOptions 中的选项绑定到命令标志
    opts.AddFlags(cmd.PersistentFlags())

    return cmd
}

上述代码,调用 opts.AddFlags() 方法,将 ServerOptions 中选定的字段绑定到了 cmd.PersistentFlags() 标志集中。


添加并解析配置文件


应用的绝大部分配置优先考虑使用配置文件来配置,并且配置文件格式建议采用易读性更好的 YAML 格式。本节来详细介绍下,在 Go 应用开发的过程中,具体如何编码实现配置文件的读取。


cobra 支持通过 cobra.OnInitialize(y ...func()) 函数注册一个回调函数,该回调函数在每次运行任意命令时都会被调用。可以借助该方法,来注册一个配置文件加载函数,在每次运行命令时,加载配置文件。加载代码如下:

func NewMiniBlogCommand() *cobra.Command {
    // 创建默认的应用命令行选项
    opts := options.NewServerOptions()

    cmd := &cobra.Command{
        ...
    }

    // 初始化配置函数,在每个命令运行时调用
    cobra.OnInitialize(onInitialize)
    ...
    return cmd
}


onInitialize 函数实现位于 cmd/mb-apiserver/app/config.go 文件中,内容如代码清单 5-6 所示。

代码清单 5-6 onInitialize 函数代码实现

const (
    // defaultHomeDir 定义放置 miniblog 服务配置的默认目录。
    defaultHomeDir = ".miniblog"

    // defaultConfigName 指定 miniblog 服务的默认配置文件名。
    defaultConfigName = "mb-apiserver.yaml"
)

// onInitialize 设置需要读取的配置文件名、环境变量,并将其内容读取到 viper 中。
func onInitialize() {
    if configFile != "" {
        // 从命令行选项指定的配置文件中读取
        viper.SetConfigFile(configFile)
    } else {
        // 使用默认配置文件路径和名称
        for _, dir := range searchDirs() {
            // 将 dir 目录加入到配置文件的搜索路径
            viper.AddConfigPath(dir)
        }

        // 设置配置文件格式为 YAML
        viper.SetConfigType("yaml")

        // 配置文件名称(没有文件扩展名)
        viper.SetConfigName(defaultConfigName)
    }

    // 读取环境变量并设置前缀
    setupEnvironmentVariables()

    // 读取配置文件。如果指定了配置文件名,则使用指定的配置文件,否则在注册的搜索路径中搜索
    if err := viper.ReadInConfig(); err != nil {
        log.Printf("Failed to read viper configuration file, err: %v", err)
    }

    // 打印当前使用的配置文件,方便调试
    log.Printf("Using config file: %s", viper.ConfigFileUsed())
}

// setupEnvironmentVariables 配置环境变量规则。
func setupEnvironmentVariables() {
    // 允许 viper 自动匹配环境变量
    viper.AutomaticEnv()
    // 设置环境变量前缀
    viper.SetEnvPrefix("MINIBLOG")
    // 替换环境变量 key 中的分隔符 '。' 和 '-' 为 '_'
    replacer := strings.NewReplacer(".", "_", "-", "_")
    viper.SetEnvKeyReplacer(replacer)
}

// searchDirs 返回默认的配置文件搜索目录。
func searchDirs() []string {
    // 获取用户主目录
    homeDir, err := os.UserHomeDir()
    // 如果获取用户主目录失败,则打印错误信息并退出程序
    cobra.CheckErr(err)
    return []string{filepath.Join(homeDir, defaultHomeDir), "."}
}

在代码清单 5-6 中,viper 包会优先从 configFile 中指定的配置文件中加载配置。如果 configFile 为空,则会加载默认的配置文件。viper.AddConfigPath(dir) 用来指定默认配置文件的加载路径。配置文件加载路径通过 searchDirs() 函数返回,searchDirs() 函数代码实现如下:

// searchDirs 返回默认的配置文件搜索目录。
func searchDirs() []string {
    // 获取用户主目录
    homeDir, err := os.UserHomeDir()
    // 如果获取用户主目录失败,则打印错误信息并退出程序
    cobra.CheckErr(err)
    return []string{filepath.Join(homeDir, defaultHomeDir), "."}
}


在代码清单 5-6 中,viper.SetConfigType("yaml") 用来指定配置文件格式(YAML),viper.SetConfigName() 用来指定配置文件名。最终,当 configFile 为空时,默认会从当前目录(.)和$HOME/.miniblog/ 目录下加载名为 mb-apiserver.yaml 的配置文件,并且以 YAML 格式处理配置文件。在设置了配置文件路径加载规则后,便可以使用 viper.ReadInConfig() 函数来读取配置文件。


在代码清单 5-6 中,setupEnvironmentVariables() 函数用来配置环境变量读取规则,其代码实现如下:

// setupEnvironmentVariables 配置环境变量规则。
func setupEnvironmentVariables() {
    // 允许 viper 自动匹配环境变量
    viper.AutomaticEnv()
    // 设置环境变量前缀
    viper.SetEnvPrefix("MINIBLOG")
    // 替换环境变量 key 中的分隔符 '。' 和 '-' 为 '_'
    replacer := strings.NewReplacer(".", "_", "-", "_")
    viper.SetEnvKeyReplacer(replacer)
}

上述代码中,viper.AutomaticEnv() 用来开启 viper 读取环境变量的功能。当多个服务同时在一台机器上运行时,可能会出现环境变量名称冲突,解决办法是给所有的环境变量添加一个环境变量前缀,例如:MINIBLOG_JWT_KEY,以此降低名称冲突的概率。可以通过 viper.SetEnvPrefix("MINIBLOG") 来给所有的环境变量添加一个 MINIBLOG_ 前缀。


strings.NewReplacer(".", "_", "-", "_") 用来进行键名转换(例如将。替换成 _,将 - 替换成 _),确保环境变量命名与配置键名兼容。


在 Go 应用开发中,通过给变量设置默认值是一个好的开发习惯,通过设置默认值,可以简化应用启动时的配置,规避配置缺失时带来的程序异常。另外,在 onInitialize() 函数中,指定了默认加载的配置文件,通过这种设置默认值的方式,在开发、测试时,可以直接运行二进制程序,程序自行查找默认的配置,省去了指定配置文件的操作,可以方便开发者开发测试。但在生产环境,不建议使用这种不感知的配置加载方式,更建议明确指定加载的配置文件路径,例如:

$ _output/mb-apiserver -c $HOME/.miniblog/mb-apiserver.yaml


更新配置文件内容


通过 cobra.OnInitialize(onInitialize) 函数调用,加载了配置文件。这时候,还需要将配置文件内容更新到 ServerOptions 配置项变量中,供程序引用。方法如下:

func NewMiniBlogCommand() *cobra.Command {
    // 创建默认的应用命令行选项
    opts := options.NewServerOptions()
    cmd := &cobra.Command{
        ...
        RunE: func(cmd *cobra.Command, args []string) error {
            // 将 viper 中的配置解析到选项 opts 变量中。 
            if err := viper.Unmarshal(opts); err != nil {
                return err
            }

            // 对命令行选项值进行校验。
            if err := opts.Validate(); err != nil {
                return err
            }
            ...
            return nil
        },
        ...
    }
    ...
    return cmd
}

上述代码中,viper.Unmarshal 方法可以将 viper 中加载的配置项及值解析到 opts 变量的绑定字段中。opts.Validate() 用来对配置项进行校验,确保初始化配置是合法可用的。


读取配置内容


在添加完配置文件解析代码之后,便可以在代码中读取配置。有以下两种读取配置的方式:

func NewMiniBlogCommand() *cobra.Command {
    // 创建默认的应用命令行选项
    opts := options.NewServerOptions()
    cmd := &cobra.Command{
        ...
        // 指定调用 cmd.Execute() 时,执行的 Run 函数
        RunE: func(cmd *cobra.Command, args []string) error {
            ...
            fmt.Printf("ServerMode from ServerOptions: %s\n", opts.JWTKey)
            fmt.Printf("ServerMode from Viper: %s\n\n", viper.GetString("jwt-key"))
          
            jsonData, _ := json.MarshalIndent(opts, "", "  ")
            fmt.Println(string(jsonData))

            return nil
        },
        ...
    }
    ...
    return cmd
}

在代码中建议使用更加显式的 opts.JWTKey 方式来引用配置项。viper.GetString 这种方式可以使用,但不建议使用。因为引用感知度不强,可能会带来潜在的问题,例如传给 viper.GetString 的配置项名称不存在。


提示:
在 Go 项目开发中,要慎重使用感知度不强的开发方式,例如:使用 init() 方法、全局变量、隐式错误处理等。这些隐式的实现,很可能会因为开发者感知不到,而带来理解、维护成本,甚至带来程序缺陷。



viper 包提供了很多有用的函数,可以极大的方便我们读取 viper 中加载的配置。例如:

  1. 使用 viper.AllSettings() 函数返回所有的配置内容;
  2. 使用 viper.Get<type>(key) 获取指定 key 的配置值,<type> 指的是配置项的数据类型。key 支持缩进形式,例如 mysql.username 实际取的是 username 的值 miniblog:
# MySQL 数据库相关配置
mysql:
  # MySQL 机器 IP 和端口,默认 127.0.0.1:3306
  addr: 127.0.0.1:3306
  # MySQL 用户名(建议授权最小权限集)
  username: miniblog

至此,miniblog 应用已经完成了配置功能的开发,完整源码位于 miniblog 项目的 feature/s03 分支。


测试配置读取功能


新建一个测试配置文件 /tmp/mb-apiserver.yaml,内容如下:

# 服务器类型,可选值有:
#   grpc:启动一个 gRPC 服务器
#   grpc-gateway: 启动一个 gRPC 服务器 + HTTP 反向代理服务器
#   gin:基于 gin 框架启动一个 HTTP 服务器
# 服务器模式选择:
#   - 应用内调用选择 grpc
#   - 如果有外部服务调用选择 grpc-gateway
#   - 学习 Gin 框架时选择 gin
server-mode: grpc-gateway
# JWT 签发密钥
jwt-key: Rtg8BPKNEf2mB4mgvKONGPZZQSaJWNLijxR42qRgq0iBb5

在创建配置文件时,建议给配置项添加详细的说明,可以降低配置文件的理解成本。


执行以下命令编译并运行 mb-apiserver 程序,测试配置是否被正确读取:

$ make build
$ _output/mb-apiserver -c /tmp/mb-apiserver.yaml #读取配置文件及配置项的值
...
{
  "server-mode": "grpc",
  "jwt-key": "Rtg8BPKNEf2mB4mgvKONGPZZQSaJWNLijxR42qRgq0iBb5",
  "expiration": 7200000000000
}
$ _output/mb-apiserver -c /tmp/mb-apiserver.yaml --expiration 3h #通过命令行选项设置配置项
...
{
  "server-mode": "grpc",
  "jwt-key": "Rtg8BPKNEf2mB4mgvKONGPZZQSaJWNLijxR42qRgq0iBb5",
  "expiration": 10800000000000
}
$ export MINIBLOG_JWT_KEY=XXXXXXXXX #通过环境变量设置配置项
$ _output/mb-apiserver -c /tmp/mb-apiserver.yaml 
...
{
  "server-mode": "grpc",
  "jwt-key": "XXXXXXXXX",
  "expiration": 7200000000000
}

上述测试分别测试了通过配置文件设置配置项、通过环境变量设置配置项、通过命令行选项设置配置项 3 种应用配置方式,可以看到程序均能正确获取配置项的值。


为了方便 air 热加载 mb-apiserver 应用,可以将/tmp/mb-apiserver.yaml 文件放置在 $HOME/.miniblog/ 目录中。当使用 air 运行 _output/mb-apiserver 命令时,应用会加载默认的 $HOME/.miniblog/mb-apiserver.yaml 配置文件。



提示:
本课程后边的内容,如果没有明确提示,默认加载的配置文件均为$HOME/.miniblog/mb-apiserver.yaml。



基于初始化配置创建应用


根据 miniblog 应用构建模型,在确保初始化配置可以正确加载并读取之后,就可以基于初始化配置来创建运行时配置,进而基于运行时配置创建出一个服务实例,并运行服务实例。


在 Go 项目开发中,有两种启动服务的代码编写风格:面向对象风格和函数式风格。代码示例如代码清单 5-7 所示。

代码清单 5-7 服务启动编写风格

package server

import (
    "fmt"
)

// 风格一:面向对象风格
// 运行服务:NewUnionServer()。Run()
type UnionServer struct{}

// NewUnionServer 根据配置创建联合服务器。
func NewUnionServer() *UnionServer {
    return &UnionServer{}
}

// Run 运行应用。
func (s *UnionServer) Run() {
    fmt.Println("Run union server")
    select {}
}

// 风格二:函数式风格
// 运行服务:RunUnionServer()
func RunUnionServer() {
    fmt.Println("Run union server")
    select {}
}

在开发中,推荐的风格是面向对象风格。相较于函数式风格,面向对象风格通过 UnionServer 结构体封装服务相关的功能,便于扩展和维护,符合面向对象编程的思想,更适合复杂的大型项目。在面向对象风格中,如果服务的运行需要依赖某些属性或配置(比如数据库连接、运行时状态等),可以将这些属性作为 UnionServer 结构体的字段,统一管理,逻辑更加清晰。另外,面向对象风格的模块化设计,更有利于测试用例的编写。


miniblog 项目采用了面向对象风格。miniblog 运行时代码保存在 internal/apiserver 目录下,在该目录下创建 server.go 文件,代码内容如代码清单 5-8 所示。

代码清单 5-8 实现运行时代码

package apiserver

import (
    "encoding/json"
    "fmt"
    "time"

    "github.com/spf13/viper"
)

// Config 配置结构体,用于存储应用相关的配置。
// 不用 viper.Get,是因为这种方式能更加清晰的知道应用提供了哪些配置项。
type Config struct {
    ServerMode string
    JWTKey     string
    Expiration time.Duration
}

// UnionServer 定义一个联合服务器。 根据 ServerMode 决定要启动的服务器类型。
type UnionServer struct {
    cfg *Config
}

// NewUnionServer 根据配置创建联合服务器。
func (cfg *Config) NewUnionServer() (*UnionServer, error) {
    return &UnionServer{cfg: cfg}, nil
}

// Run 运行应用。
func (s *UnionServer) Run() error {
    fmt.Printf("ServerMode from ServerOptions: %s\n", s.cfg.JWTKey)
    fmt.Printf("ServerMode from Viper: %s\n\n", viper.GetString("jwt-key"))

    jsonData, _ := json.MarshalIndent(s.cfg, "", "  ")
    fmt.Println(string(jsonData))

    select {}
    return nil
}


代码清单 5-8 中,创建了运行时配置 Config,Config 结构体类型实现了 NewUnionServer() 方法,该方法可以用来创建 *UnionServer 类型的服务实例,*UnionServer 实例的 Run 方法用来启动服务。


提示:
因为 miniblog 会同时实现 HTTP 服务和 gRPC 服务,所以将代表服务的结构体命名为 UnionServer(联合服务),以通过名字能来反映 miniblog 的服务特点。


运行时配置是基于初始化配置创建的,所以可以直接给 ServerOptions 结构体添加一个 Config 方法,用来创建运行时配置,在 cmd/mb-apiserver/app/options/options.go 文件中,添加以下代码:

package options

import (
    ...
    "github.com/onexstack/miniblog/internal/apiserver"
)
...
// Config 基于 ServerOptions 创建新的 apiserver.Config。
func (o *ServerOptions) Config() (*apiserver.Config, error) {
    return &apiserver.Config{
        ServerMode: o.ServerMode,
        JWTKey:     o.JWTKey,
        Expiration: o.Expiration,
    }, nil
}


这里要注意,cmd/mb-apiserver/app/ 目录下的文件导入了 internal/apiserver 目录下的包,也就是“控制面”依赖“数据面”。为了避免循环依赖,要避免反向导入。


cmd/mb-apiserver/app/server.go 文件中,添加以下代码来创建运行时配置,并基于运行时配置创建 UnionServer 服务实例,并运行 UnionServer 服务实例的 Run 方法启动服务,实现代码如代码清单 5-9 所示。

代码清单 5-9 创建服务实例,并启动服务

package app
...
// NewMiniBlogCommand 创建一个 *cobra.Command 对象,用于启动应用程序。
func NewMiniBlogCommand() *cobra.Command {
    ...
    cmd := &cobra.Command{
        RunE: func(cmd *cobra.Command, args []string) error {
            return run(opts)
        },
        ...
    }
    ...
    return cmd
}

// run 是主运行逻辑,负责初始化日志、解析配置、校验选项并启动服务器。
func run(opts *options.ServerOptions) error {
    ...
    // 获取应用配置。
    // 将命令行选项和应用配置分开,可以更加灵活的处理 2 种不同类型的配置。
    cfg, err := opts.Config()
    if err != nil {
        return err
    }

    // 创建服务器实例。
    // 注意这里是联合服务器,因为可能同时启动多个不同类型的服务器。
    server, err := cfg.NewUnionServer()
    if err != nil {
        return err
    }

    // 启动服务器
    return server.Run()
}

代码清单 5-9 中,*cobra.Command 类型的 RunE 方法中,添加的代码逻辑越来越多,为了保持 cmd 功能的专一性:构建应用框架。这里将服务器启动的代码逻辑单独放在了 run 方法中,便于管理。


至此,miniblog 完成了运行时代码的开发,完整代码见 feature/s04 分支。


小结(AI自动生成并人工审核)


本文详细介绍了如何为 miniblog 应用集成配置功能,主要包括配置项结构体的定义、默认值的设置、通过命令行选项和配置文件加载配置的实现过程。


首先,通过定义ServerOptions结构体来集中管理配置项,并提供 NewServerOptions 函数设定默认值,确保应用在缺少配置时依然能正常运行。


其次,使用 pflag 库将配置项与命令行选项绑定,允许用户在启动应用时通过命令行快速设置核心配置。


接着,使用 viper 库支持从结构化的 YAML 配置文件中读取配置,提供灵活性和可维护性。


最后,通过面向对象风格创建运行时配置,并实现服务实例的启动,确保代码结构清晰,便于扩展和维护。整体上,本文展示了如何在 Go 应用中高效地管理和使用配置,使得应用更易于部署和维护。