关于我 壹文 项目 三五好友
学习笔记:.env 规范 📝 2024-09-23
文章摘要

学习笔记:.env 规范

嘿,大家好👋!这篇文档并不是一个教程,而是我用来辅助自己学习和个人复习的笔记。如果你对完整的内容感兴趣,并且希望通过系统学习来掌握它,那么请直接访问原文章🔗 进行学习吧!

.env 规范

示例代码

err := router.Run(":3000")

上述代码中的端口号是硬编码在代码里的,这并不是一个好的做法。我们需要对此进行优化。类似的需求还包括:

  • 数据库连接信息 💾
  • Redis 连接信息 🧠
  • 验证码复杂度 🔐
  • 邮件服务的配置 📧
  • 第三方短信的 KEY 和 密钥 📨

解决方案

我们的配置信息将分为两个层级:

  • env:环境级别的配置。
  • config:应用级别的配置。

.env 文件

一般来说,项目会在多个环境下运行,例如:

  • local:本地开发环境(开发者的本地机器)
  • testing:自动化测试环境 🤖
  • stage:接近生产环境的测试环境,方便其他成员访问和测试(编辑人员、产品经理、项目经理)
  • production:线上生产环境 🌍

不同环境下,我们将使用不同的配置。

例如,在 local 环境中,发送短信使用的是测试账号。

而在 production 环境下,我们将使用验证了公司信息的正式账号。

敏感信息处理

.env 文件里通常会存放敏感信息,因此不会将其添加到代码版本库中。如何知道 .env 里有哪些配置项呢?

我们会添加一个 .env.example 文件,配置项放在这里作为占位符,敏感的信息留空,并将此文件提交到版本库中。

当部署到新项目中时,参照此文件创建一个 .env 文件,并对其进行配置即可。

config 目录

config 目录用于存放配置信息,按照独立的逻辑区分单独的配置文件,例如数据库连接信息存放于 config/database.go 文件下。

config 里加载 .env 里的配置项,并可设置缺省值。

为什么要有 config

既然已经有了 .env 文件,为什么还需要 config 呢?

config 可以提高配置方案的灵活性。在 config 里,我们可以为每个配置项设置默认值;也可以做一些简单的数学运算,或者调用 Go 函数处理默认值。甚至可以为配置项设置一个回调函数。

config 文件需要加入代码版本控制器中,这些代码是固定的。如果需要修改一个 config 配置项,只需修改对应的 .env 文件中的配置项即可。

多个 .env 文件

单个 .env 文件的设计是为了满足一台机器一套环境变量的需求。

多个 .env 文件则是为了满足一台机器上运行多套环境变量的需求。

开发时,除了 local 环境变量,很多时候还需要 testing 相关的环境变量,testing 的配置与 local 有所不同。例如测试时,需要使用不同的数据库,以避免污染开发数据库。

我们可以通过命令行参数,在运行主程序时传递 --env=testing 的参数,程序接收到这个参数后会读取 .env.testing 文件,而不是 .env 文件。

--env 参数的值不需要限制,取到后直接读取对应的文件即可。以下是几个例子:

  • --env=testing:读取 .env.testing 文件,用以在测试环境使用不同的数据库;
  • --env=production:读取 .env.production 文件,用以在本地环境中调试线上的第三方服务配置信息(短信、邮件)。

viper 包

我们将使用 Viper 包来作为 .env 和 config 信息的基础库。 它支持以下特性:

  • 设置默认值(存入时设置)

  • 支持格式配置信息的格式包括 JSON、TOML、YAML、HCL、envfile 和 Java properties

  • 实时监控和重新读取配置文件(可选)

  • 从环境变量中读取

  • 从远程配置系统(etcd 或 Consul)读取并监控配置变化

  • 从命令行参数读取配置

  • 从 buffer 读取配置

  • 显式配置值

    Viper 的功能比较丰富,这意味着面对多变的需求时我们可以很灵活。且作为明星项目,严格测试、使用广泛,很适合作为我们的配置模块的底层包。

cast

Go 语言是强类型语言,我们在读取 .env 配置信息或者 config 信息时,需要有一个安全的类型保障机制。 Cast 是 Viper 作者的另一个开源包 ,基本上支持所有内置类型之间的转换,下面是几个例子:

// ==== 目标类型为 string 的例子 ====
cast.ToString("mayonegg")         // "mayonegg"
cast.ToString(8)                  // "8"
cast.ToString(8.31)               // "8.31"
cast.ToString([]byte("one time")) // "one time"
cast.ToString(nil)                // ""

var foo interface{} = "one more time"
cast.ToString(foo)                // "one more time"

// ==== 目标类型为 int 的例子 ====
cast.ToInt(8)                  // 8
cast.ToInt(8.31)               // 8
cast.ToInt("8")                // 8
cast.ToInt(true)               // 1
cast.ToInt(false)              // 0

var eight interface{} = 8
cast.ToInt(eight)              // 8
cast.ToInt(nil)                // 0
  1. 配置方案的实现

    config 包是我们自定的包,对 Viper 第三方库的封装。封装以下逻辑:

    • 初始化
    • 读取配置文件
    • 设置配置项
    • 读取配置项

    config 包以外的其他项目代码,将对内部使用依赖包 Viper 无感知。 这样做的好处是:如果后续因为某些特殊需求,Viper 无法满足需求,或者 Viper 不再维护,我们需要替换成更加优秀的第三方包时,除了 config 包,项目中的其他代码都不需要修改。

    1. 安装依赖包

    go get github.com/spf13/cast
    
    go get github.com/spf13/viper
    

    2. config 包

    新建文件:

    pkg/config/config.go
    
// Package config 负责配置信息
package config

import (
    "gohub/pkg/helpers"
    "os"

    "github.com/spf13/cast"
    viperlib "github.com/spf13/viper" // 自定义包名,避免与内置 viper 实例冲突
)

// viper 库实例
var viper *viperlib.Viper

// ConfigFunc 动态加载配置信息
type ConfigFunc func() map[string]interface{}

// ConfigFuncs 先加载到此数组,loadConfig 再动态生成配置信息
var ConfigFuncs map[string]ConfigFunc

func init() {

    // 1. 初始化 Viper 库
    viper = viperlib.New()
    // 2. 配置类型,支持 "json", "toml", "yaml", "yml", "properties",
    //             "props", "prop", "env", "dotenv"
    viper.SetConfigType("env")
    // 3. 环境变量配置文件查找的路径,相对于 main.go
    viper.AddConfigPath(".")
    // 4. 设置环境变量前缀,用以区分 Go 的系统环境变量
    viper.SetEnvPrefix("appenv")
    // 5. 读取环境变量(支持 flags)
    viper.AutomaticEnv()

    ConfigFuncs = make(map[string]ConfigFunc)
}

// InitConfig 初始化配置信息,完成对环境变量以及 config 信息的加载
func InitConfig(env string) {
    // 1. 加载环境变量
    loadEnv(env)
    // 2. 注册配置信息
    loadConfig()
}

func loadConfig() {
    for name, fn := range ConfigFuncs {
        viper.Set(name, fn())
    }
}

func loadEnv(envSuffix string) {

    // 默认加载 .env 文件,如果有传参 --env=name 的话,加载 .env.name 文件
    envPath := ".env"
    if len(envSuffix) > 0 {
        filepath := ".env." + envSuffix
        if _, err := os.Stat(filepath); err == nil {
            // 如 .env.testing 或 .env.stage
            envPath = filepath
        }
    }

    // 加载 env
    viper.SetConfigName(envPath)
    if err := viper.ReadInConfig(); err != nil {
        panic(err)
    }

    // 监控 .env 文件,变更时重新加载
    viper.WatchConfig()
}

// Env 读取环境变量,支持默认值
func Env(envName string, defaultValue ...interface{}) interface{} {
    if len(defaultValue) > 0 {
        return internalGet(envName, defaultValue[0])
    }
    return internalGet(envName)
}

// Add 新增配置项
func Add(name string, configFn ConfigFunc) {
    ConfigFuncs[name] = configFn
}

// Get 获取配置项
// 第一个参数 path 允许使用点式获取,如:app.name
// 第二个参数允许传参默认值
func Get(path string, defaultValue ...interface{}) string {
    return GetString(path, defaultValue...)
}

func internalGet(path string, defaultValue ...interface{}) interface{} {
    // config 或者环境变量不存在的情况
    if !viper.IsSet(path) || helpers.Empty(viper.Get(path)) {
        if len(defaultValue) > 0 {
            return defaultValue[0]
        }
        return nil
    }
    return viper.Get(path)
}

// GetString 获取 String 类型的配置信息
func GetString(path string, defaultValue ...interface{}) string {
    return cast.ToString(internalGet(path, defaultValue...))
}

// GetInt 获取 Int 类型的配置信息
func GetInt(path string, defaultValue ...interface{}) int {
    return cast.ToInt(internalGet(path, defaultValue...))
}

// GetFloat64 获取 float64 类型的配置信息
func GetFloat64(path string, defaultValue ...interface{}) float64 {
    return cast.ToFloat64(internalGet(path, defaultValue...))
}

// GetInt64 获取 Int64 类型的配置信息
func GetInt64(path string, defaultValue ...interface{}) int64 {
    return cast.ToInt64(internalGet(path, defaultValue...))
}

// GetUint 获取 Uint 类型的配置信息
func GetUint(path string, defaultValue ...interface{}) uint {
    return cast.ToUint(internalGet(path, defaultValue...))
}

// GetBool 获取 Bool 类型的配置信息
func GetBool(path string, defaultValue ...interface{}) bool {
    return cast.ToBool(internalGet(path, defaultValue...))
}

// GetStringMapString 获取结构数据
func GetStringMapString(path string) map[string]string {
    return viper.GetStringMapString(path)
}

上面有一个 helpers.Empty() 方法未定义,现在定义此方法: pkg/helpers/helpers.go

// Package helpers 存放辅助方法
package helpers

import "reflect"

// Empty 类似于 PHP 的 empty() 函数
func Empty(val interface{}) bool {
    if val == nil {
        return true
    }
    v := reflect.ValueOf(val)
    switch v.Kind() {
    case reflect.String, reflect.Array:
        return v.Len() == 0
    case reflect.Map, reflect.Slice:
        return v.Len() == 0 || v.IsNil()
    case reflect.Bool:
        return !v.Bool()
    case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
        return v.Int() == 0
    case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
        return v.Uint() == 0
    case reflect.Float32, reflect.Float64:
        return v.Float() == 0
    case reflect.Interface, reflect.Ptr:
        return v.IsNil()
    }
    return reflect.DeepEqual(val, reflect.Zero(v.Type()).Interface())
}

3. 配置配置信息

config/app.go
// Package config 站点配置信息
package config

import "gohub/pkg/config"

func init() {
    config.Add("app", func() map[string]interface{} {
        return map[string]interface{}{

            // 应用名称
            "name": config.Env("APP_NAME", "Gohub"),

            // 当前环境,用以区分多环境,一般为 local, stage, production, test
            "env": config.Env("APP_ENV", "production"),

            // 是否进入调试模式
            "debug": config.Env("APP_DEBUG", false),

            // 应用服务端口
            "port": config.Env("APP_PORT", "3000"),

            // 加密会话、JWT 加密
            "key": config.Env("APP_KEY", "33446a9dcf9ea060a0a6532b166da32f304af0de"),

            // 用以生成链接
            "url": config.Env("APP_URL", "http://localhost:3000"),

            // 设置时区,JWT 里会使用,日志记录里也会使用到
            "timezone": config.Env("TIMEZONE", "Asia/Shanghai"),
        }
    })
}

4. config/config.go

// Package config 存放程序所有的配置信息
package config

// Initialize 触发加载 config 包的所有 init 函数
func Initialize() {
}

5. 创建 .env 文件

.env
APP_ENV=local
APP_KEY=zBqYyQrPNaIUsnRhsGtHLivjqiMjBVLS
APP_DEBUG=true
APP_URL=http://localhost:3000
APP_LOG_LEVEL=debug
APP_PORT=3000Copy

6. 配置初始化

修改 main.go

package main

import (
    "flag"
    "fmt"
    "gohub/bootstrap"
    btsConfig "gohub/config"
    "gohub/pkg/config"

"github.com/gin-gonic/gin"

)

func init() {
    // 加载 config 目录下的配置信息
    btsConfig.Initialize()
}

func main() {

    // 配置初始化,依赖命令行 --env 参数
    var env string
    flag.StringVar(&env, "env", "", "加载 .env 文件,如 --env=testing 加载的是 .env.testing 文件")
    flag.Parse()
    config.InitConfig(env)

    // new 一个 Gin Engine 实例
    router := gin.New()

    // 初始化路由绑定
    bootstrap.SetupRoute(router)

    // 运行服务
    err := router.Run(":" + config.Get("app.port"))
    if err != nil {
        // 错误处理,端口被占用了或者其他错误
        fmt.Println(err.Error())
    }

}

7. init 方法

Go 中有两个特殊的函数:

  1. main 包中的 main 函数,它是所有 Go 可执行程序的入口函数。
  2. 包级别的 init 函数

init 函数是一个无参无返回值的函数:

func init() {
        ...
}

init 函数逻辑

  • Go 运行时会负责在该包初始化时调用它的 init 函数;
  • init 不能被显式调用;
  • 每个 init 函数在整个 Go 程序生命周期内仅会被执行一次;
  • 同一个源文件中的多个 init 函数按声明顺序依次执行。

8. 测试

启动后,可以看到:

[GIN-debug] Listening and serving HTTP on :3000

修改 .env 中的 APP_PORT 项:

APP_PORT=2000

关注命令行窗口是否输出:

[GIN-debug] Listening and serving HTTP on :2000

9. go mod tidy

整理 go.mod 文件:

go mod tidy
Not-By-AI