diff --git a/.gitignore b/.gitignore index f2dd9554a12fd7acdc62e60e8eccae086f718be2..9ef7b5e10b431d85bfd4f10801a83f119e2b5e90 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ *.dll *.so *.dylib +.idea # Test binary, built with `go test -c` *.test diff --git a/chap-33-application-configuration/33.md b/chap-33-application-configuration/33.md new file mode 100644 index 0000000000000000000000000000000000000000..0082d70e207376bcb79794158de14e4facf2fbe9 --- /dev/null +++ b/chap-33-application-configuration/33.md @@ -0,0 +1,468 @@ +# 第 33 章:应用程序配置 + +![](imgs/33-1-application.jpg) + +## 1 您将在本章学到什么? + +- 如何配置一个应用程序? +- 如何给程序加一个命令行选项? +- 如何创建和解析环境变量? + +## 2 涉及到的技术概念 + +- 环境变量 +- 解构 +- 命令行选项 + +## 3 介绍 + +当我们写程序时,(经常)会将一些重要的选项写进源码里,例如:服务监听的端口,使用的数据库名称等等。但是如果您尝试将您写的程序分享给别人呢?很明显在开发阶段您的用户不会和您使用同样的数据库名称;他们也想给自己的程序使用不同的端口号! + +并且还有安全问题呢!您想让每个人都知道您的数据库密码吗?您不应该提交这些凭证到您的代码中。 + +## 4 它是什么? + +当应用程序向用户提供控制执行方面的可能性时,它是可配置的[@rabkin2011static]。例如,用户可以自定义服务监听的端口,数据库凭证,HTTP 请求的超时时间等等。 + +## 5 该如何做呢? + +在许多开源应用程序中,配置项都是通过 键-值 数据结构处理的[@rabkin2011static]。配置经常通过一个独一无二的名字标识的。Unix 系统使用字符串作为选项名字。Windows 使用一种树状结构。 + +在实际的应用中,您总是会找到一个 class(或一个类型结构体)将配置选项暴露出去。这个 class 经常解析一个文件将值赋给每个选项。应用程序通常建议使用命令行选项来调整配置。 + +## 6 命令行选项 + +为了定义一个命令行选项的配置名称,我们可以使用标准库 `flag` 。 + +举个例子。您在构建一个 REST API 服务。您的应用将会暴露一个网络服务;您想让用户自己配置监听端口。 + +第一步,先定义一个新标识: + +``` Go +// configuration/cli/main.go + +port := flag.Int("port", 4242, "the port on which the server will listen") +``` + +第一个参数是标识名;第二个参数是默认值;第三个是帮助文本。返回值是一个 integer 型的指针。 + +``` Go +var port int +flag.IntVar(&port, "port", 4242, "the port on which the server will listen") +``` + +下一步就是解析这些标识: + +``` Go +flag.Parse() +``` + +在您使用自己定义的这些标识前,`Parse` 函数必须被调用。在函数内部,它将遍历命令行中给出的所有参数,并为您定义的标识变量赋值。 + +在第一版(`flag.Int`)中,变量将成为一个数字型指针(**`*int`**)。在第二版(`flag.Intvar`)将成为数字类型(**`int`**)。这种特殊性改变了您以后使用变量的方式: + +``` Go +// version 1 : Int + +port := flag.Int("port", 4242, "the port on which the server will listen") +flag.Parse() +fmt.Printf("%d",*port) + +// version 2 : IntVar + +var port2 int flag.IntVar(&port2, "port2", 4242, "the port on which the server will listen") flag.Parse() +fmt.Printf("%d\n",port2) +``` + +### 6.0.1 其他类型 + +`flag` 包提供了一些函数来添加不同类型的标识。下面左边是一些类型和 flag 包的相关函数。 + +Int64, Int64Var + +String, StringVar + +Uint, UintVar + +Uint64, Uint64Var + +Float64, Float64Var + +Duration, DurationVar + +Bool, BoolVar + +### 6.0.2 在命令行中的用法 + +有两种方法将指定的标识传给程序 + +``` Bash +$ myCompiledGoProgram -port=4242 +``` +或者 +``` Bash +$ myCompiledGoProgram -port 4242 +``` + +bool 标识类型禁止使用最后一种用法。您的应用可以简单的写成这样来设置标识为 true: + +``` Bash +$ myCompiledGoProgram -myBoolFlag +``` + +如此做,标识背后的变量将会被设置为 true。 + +您可以使用 help flag 来查看所有可用的标识(这是默认添加到您的程序中的) + +``` Bash +$ ./myCompiledGoProgram -help +Usage of ./myCompiledProgram: + -myBool + a test boolean + -port int + the port on which the server will listen (default 4242) + -port2 int + the port on which the server will listen (default 4242) +``` + +## 7 环境变量 + +一些云服务通过环境变量支持配置。环境变量使用 export 命令很容易配置。只需要在您的终端输入以下命令即可创建一个名为 MYVAR 的环境变量1: + +``` Bash +$ export MYVAR=value +``` + +如果您想将环境变量传递给应用程序,使用以下语句: + +``` Bash +$ export MYVAR=test && export MYVAR2=test2 && ./myCompiledProgram +``` + +这里我们设置了两个环境变量,并启动了程序 `myCompiledProgram`。 + +### 7.0.1 如何获取环境变量的值 + +您可以使用 `os` 标准包获取环境变量的值。该包暴露了一个名为 `Getenv` 的方法。这个方法使用变量的名字作为参数并且返回它的值(以字符串的形式): + +``` Go +// configuration/env/main.go +myvar := os.Getenv("MYVAR") +myvar2 := os.Getenv("MYVAR2") +fmt.Printf("myvar : '%s'\n", myvar) +fmt.Printf("myvar2 :'%s'\n", myvar2) +``` + +您可以使用其他的方法获取环境变量的值:`LookupEnv`。它比前一种方法要好,因为如果变量不存在的话它会通知您: + +``` Go +// configuration/env/main.go +port, found := os.LookupEnv("DB_PORT") +if !found { + log.Fatal("impossible to start up, DB_PORT env var is mandatory") +} +portParsed, err := strconv.ParseUint(port, 10, 8) +if err != nil { + log.Fatalf("impossible to parse db port: %s", err) +} +log.Println(portParsed) +``` + +`LookupEnv` 将会检查环境变量是否存在: + +- 如果不存在,第二个结果(`found`)将等于 `false` +- 在上面的例子中,我们使用 `strconv.ParseUint` 方法将端口值解析为 uint16(基于十进制) + +### 7.0.2 如何获取所有的环境变量 + +您可以使用 Environ() 方法获取所有的环境变量,其返回一个字符串类型的 slice: + +``` Go +fmt.Println(os.Environ()) +``` + +返回的 slice 以『键=值』的格式组成所有的变量。slice 的每个元素都是一个 键-值 对。包并不会从键中分割值,您必须解析 slice 的值。 + +### 7.0.3 如何设置一个环境变量 + +os 包暴露了 `os.Setenv` 方法: + +``` Go +err := os.Setenv("MYVAR3","test3") +if err != nil { + panic(err) +} +``` + +`os.Setenv` 方法接收两个参数: + +- 设置的变量名 +- 它的值 + +注意,它会产生错误,您要记得处理错误。 + +## 8 基于文件的配置 + +应用可以保存为文件并且被应用自动解析。 + +文件的格式依赖于应用的需求。如果您需要一个带着等级和层级(以 Windows Registry 的方式)的复杂配置,可以考虑使用 JSON,YAML,或者 XML 格式。 + +我们看一下 JSON 配置文件的例子: + +``` Json +{ + "server": { + "host": "localhost", + "port": 80 + }, + "database": { + "host": "localhost", + "username": "myUsername", + "password": "abcdefgh" + } +} +``` + +我们把这个文件放置在托管应用程序的某个地方。在应用程序里解析这些文件以获取配置值。 + +为了解析它,首先在程序中创建这样一个结构体: + +``` Go +// configuration/file/main.go +type Configuration struct { + Server Server `json:"server"` + Database DB `json:"database"` +} +type Server struct { + Host string `json:"host"` + Port int `json:"port"` +} +type DB struct { + Host string `json:"host"` + Username string `json:"username"` + Password string `json:"password"` +} +``` + +我们创建了三种类型的结构体: + +- `Configuration` 包括了其它两种类型 +- `Server` 代表了 JSON 对象 **server** +- `DB` 存储了 JSON 对象 **database** + +您可以使用这些类型的结构体解析这个 JSON 配置文件。下一步就是打开文件读取内容: + +``` Go +// configuration/file/main.go + +confFile, err := os.Open("myConf.json") +if err != nil { + panic(err) +} +defer confFile.Close() +conf, err := ioutil.ReadAll(confFile) +if err != nil { + panic(err) +} +``` + +这里我们调用 `os.Open` 函数打开位于可执行文件同级目录中的 `myConf.json` 文件。在实际的环境中,您应该把文件上传到机器的合适位置。 + +通过使用 `ioutil.ReadAll` 函数获取整个文件的内容。该函数返回一个 byte 类型的 slice。下一步就是使用 `json.Unmarshal` 函数处理这个 slice: + +``` Go +// configuration/file/main.go + +myConf := Configuration{} +err = json.Unmarshal(conf, &myConf) +if err != nil { + panic(err) +} +fmt.Printf("%+v",myConf) +``` + +我们首先创建了一个变量 `myConf` 初始化为一个空结构体 `Configuration`。然后我们把从文件中提取出的 byte 类型的 slice 传给 `json.Unmarshal` 函数,和(第二个参数)一个 `myConf` 指针地址。 + +最后,我们的程序会输出如下: + +``` Go +{Server:{Host:localhost Port:80} Database:{Host:localhost Username:myUsername Password:abcdefgh}} +``` + +您可以在整个应用程序中共享这个变量。 + +## 9 github.com/spf13/viper 模块 + +`github.com/spf13/viper` 是非常受当前 Go 社区欢迎的配置模块,其拥有超过 14,000 星和超过 1.3k forks2。 + +这个包允许您通过文件、环境变量、命令行、buffers 等等简单定义一个配置。它亦支持一个有趣的特性:如果您的配置项在应用程序的生命周期中改变了,它将会重载。 + +### 9.1 从文件中读取 + +尝试一下: + +``` Go +// configuration/viper/main.go +//... +// import "github.com/spf13/viper" +viper.SetConfigName("myConf") +viper.(".") +err := viper.ReadInConfig() +if err != nil { + log.Panicf("Fatal error config file: %s\n", err) +} +``` + +首先,您必须定义您想加载的配置文件的名字:`viper.SetConfigName("myConf")`。这里我们加载了名为 `myConf` 的文件,注意不能给 viper 文件的扩展名。其将会检索和解析正确的文件。 + +在下一行,必须告诉 viper 使用函数 `AddConfigPath` 搜索。点意味着『在当前文件夹下查找』名为 myConf 的文件。当前支持的扩展名如下: + +- json +- toml +- yaml +- yml +- properties +- props +- prop +- hcl + +注意应该为配置添加其它的路径。`AddConfig` 函数将会添加这些路径到一个 slice 中。 + +当我们调用 `ReadInConfig` 函数时,包将会搜寻这个特定的文件。 + +可以加载使用如下配置: + +``` Go +fmt.Println(viper.AllKeys()) +//[database.username database.password server.host server.port database.host] +fmt.Println(viper.GetString("database.username")) +// myUsername +``` + +使用 `AllKeys` 函数,可以检索应用程序的所有存在的配置键。使用 `GetString` 函数获取一个特定的配置变量。 + +`GetString` 函数接收配置项的键作为参数,键是配置文件层次结构的反映。这里的 `"database.username"` 意思是我们从 **database** 对象中获取 **username** 属性。 + +database 对象也有 host 熟悉,可以简单的通过 `"database.host"` 获取。 + +viper 暴露 `GetXXX` 函数这些类型 **`bool`**,**`float64`**,**`int`**,**`string`**,**`map[string]interface{}`**,**`map[string]string`**,slice of strings,time 和 duration。也可以简单地使用 Get 获取配置项的值,返回的类型是空接口。我并不推荐这么做,因为其可能导致类型错误。在我们的例子中,我们期望字符串,但是如果一个用户把数字填入配置文件中,它就会返回一个数字。您的程序将会出现预期外的罢工和 panic。 + +### 9.2 从环境变量中读取 + +Viper 可以使用 prefix 自动解析环境变量: + +``` Go +viper.SetEnvPrefix("myapp") +viper.AutomaticEnv() +``` + +有了这两行之后,您每次调用一个方法(`GetString`,`GetBool` 等等)viper 将会查找带着 MYAPP_ 前缀的环境变量: + +``` Go +fmt.Println(viper.GetInt("timeout")) +``` + +将会查找名为 `MYAPP_TIMEOUT` 的环境变量。 + +## 10 问题以及如何避免 + +### 10.1 文档缺失 + +许多项目都缺失关于配置项的文档。 + +通过在一个单独的文件中记录每一个可用配置选项,可以降低软件的采用门槛。 + +在公司内部,您可能不是处理应用程序部署的人,因此,您必须在您的估计中包括文档时间。更好的文档意味着减少了解决方案的上市时间。 + +来自 Berkeley University[@rabkin2011static] 的 Ariel Rabkin 和 Randy Katz 的一项有趣的研究表明,甚至一些大型开源项目(他们研究了 Cassandra,Hadoop,HBase 等七个大型项目)的配置文档都是不准确的: + +- 其中提到了应用程序源代码中不存在的配置项。他们指出,有时这些选项只是简单地注释到应用程序源代码中。 +- 应用程序源代码中存在一些甚至没有文档记录的选项。 + +解决方法很简单:为您的配置选项创建精确的文档,并在项目开发时保持最新。 + +### 10.2 配置错误 + +在一项研究中[@nagaraja2004understanding]技术操作人员被要求在一个真实的互联网系统(三层拍卖应用程序)上执行操作。据观察,50%的错误是配置错误! + +这个数字令人印象深刻,也非常有趣。系统可能会因为配置错误而失败。您无法阻止用户在配置中输入错误的端口号。但是应用程序可以保护自己不受错误配置的影响。 + +- 通过检查和警告用户。 +- 不使用默认值替换错误值。 + +``` Go +// configuration/error-detection/main.go +//... +dbPortRaw := os.Getenv("DATABASE_PORT") +dbPort, err := strconv.ParseUint(port, 10, 16) +if err != nil { + log.Panicf("Impossible to parse database port number '%s'. Please double check the env variable DATABASE_PORT",dbPortRaw) +} +``` + +在前面的代码里,我们检索环境变量 `DATABASE_PORT` 的值。接着尝试把它转换为 **`uint16`** 类型。如果出现错误,我们拒绝启动应用程序并且通知用户错误原因。 + +注意这里应该通知用户如何修复这个错误。这是一个好的例子,仅仅花费您十秒钟的时间写,但是可能省去了用户一个小时的查找错误时间。 + +再次检查程序是否有效地使用了所有配置变量。 + +## 11 安全 + +配置变量通常由这些组成:密码、访问令牌、加密密钥等等。泄露这些机密可能会造成损失。以上所有解决方案都不能完美地存储和保护生产机密。频繁变换这些机密信息也不是一个很好地解决方案。 + +一些开源解决方案可以处理这个具体的问题。它们提供了一系列功能来保护、监视和审计您的机密信息的使用。在写作的时候,似乎由 Hashicorp 编辑的 Vault 是最流行的3。而且它是用 Go 写的! + +## 12 自我测试 + +### 12.1 问题 + +1. 如何给程序添加一个字符串类型的命令行选项? +2. 如何检查一个环境变量是否存在,并且在同一个调用中获取它的值? +3. 如何设置一个环境变量? +4. 然后使用文件配置应用程序? + +### 12.2 答案 + +1. 如何给程序添加一个字符串类型的命令行选项? + 1. ``` var password stringflag.StringVar(&password,"password", "default","the db password")// define other flags// parse input flagsflag.Parse() ``` + 2. ``` password2 := flag.String(“password2”,“default”, “the db password”) ``` +2. 如何检查一个环境变量是否存在,并且在同一个调用中获取它的值? + 1. 使用 `os` 包中的 `LookupEnv` 函数 + 2. ``` port, ok := os.LookupEnv(“DB_PORT”) if !ok { log.Fatal(“impossible to start up, DB_PORT env var is mandatory”) } ``` +3. 如何设置一个环境变量? + 1. 使用 `os` 包中的 `SetEnv` 函数 + 2. ``` err := os.Setenv(“MYVAR3”, “test3”) if err != nil { panic(err) } ``` +4. 然后使用 JSON 文件配置应用程序? + 1. 使用所有配置变量创建一个特定类型的文件 + 2. 在引导程序里,打开 JSON 文件 + 3. 解析文件。 + +## 13 关键要点 + +- 应用程序配置可以通过命令行选项配置。 +- `flag` 标准包提供了添加这些选项的方法。 +``` Go +var password string +flag.StringVar(&password,"password", "default","the db password")// define other flags// parse input flagsflag.Parse() +``` +- 配置项在程序启动的时候通过命令行给出:```$ myCompiledGoProgram -password insecuredPass``` +- 配置项通常在应用程序启动的时候加载(在 main 函数中) +- 应用程序配置项也能通过环境变量设置 +- 可以使用 `os` 包检索环境变量 + - ```dbHost := os.GetEnv(“DB_HOST”)``` + - ```dbHost, found := os.LookupEnv(“DB_HOST”)``` +- 也可以通过文件(JSON,YAML...)处理配置项。做法就是创建对应的结构体并解构文件内容 +- `github.com/spf13/viper` 是处理应用程序配置的常用参考模块 +- 配置项应该被开发者认真记录为文档。及时更新的文档也是相当有竞争力的 +- 使用专用的机密信息管理解决方案应处理应用程序机密信息。 + +--- +1. 在Unix系统中,环境变量是在其上定义的进程的本地变量↩︎ +2. 截止到2021年2月22日 +3. 数据源自:https://github.com/search?o=desc&q=secrets+management&s=stars&type=Repositories 截止到2021年2月22日 + +## 参考文献 +- [rabkin2011static] Rabkin, Ariel, and Randy Katz. 2011. “Static Extraction of Program Configuration Options.” In Proceedings of the 33rd International Conference on Software Engineering, 131–40. ACM. +- [rabkin2011static] Rabkin, Ariel, and Randy Katz. 2011. “Static Extraction of Program Configuration Options.” In Proceedings of the 33rd International Conference on Software Engineering, 131–40. ACM. +- [rabkin2011static] Rabkin, Ariel, and Randy Katz. 2011. “Static Extraction of Program Configuration Options.” In Proceedings of the 33rd International Conference on Software Engineering, 131–40. ACM. +- [nagaraja2004understanding] Nagaraja, Kiran, Fábio Oliveira, Ricardo Bianchini, Richard P Martin, and Thu D Nguyen. 2004. “Understanding and Dealing with Operator Mistakes in Internet Services.” In OSDI, 4:61–76. diff --git a/chap-33-application-configuration/imgs/33-1-application.jpg b/chap-33-application-configuration/imgs/33-1-application.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9df4fdc825a04f96d0daf6e38499371fdabe0f14 Binary files /dev/null and b/chap-33-application-configuration/imgs/33-1-application.jpg differ