519 lines
25 KiB
Markdown
519 lines
25 KiB
Markdown
# 23 | 应用构建实战:如何构建一个优秀的企业应用框架?
|
||
|
||
你好,我是孔令飞。今天我们来聊聊开发应用必须要做的那些事儿。
|
||
|
||
应用开发是软件开发工程师最核心的工作。在我这 7 年的 Go 开发生涯中,我构建了大大小小不下 50 个后端应用,深谙其中的痛点,比如:
|
||
|
||
* 重复造轮子。同样的功能却每次都要重新开发,浪费非常多的时间和精力不说,每次实现的代码质量更是参差不齐。
|
||
* 理解成本高。相同的功能,有 N 个服务对应着 N 种不同的实现方式,如果功能升级,或者有新成员加入,都可能得重新理解 N 次。
|
||
* 功能升级的开发工作量大。一个应用由 N 个服务组成,如果要升级其中的某个功能,你需要同时更新 N 个服务的代码。
|
||
|
||
想要解决上面这些问题,一个比较好的思路是:**找出相同的功能,然后用一种优雅的方式去实现它,并通过 Go 包的形式,供所有的服务使用。**
|
||
|
||
如果你也面临这些问题,并且正在寻找解决方法,那么你可以认真学习今天这一讲。我会带你找出服务的通用功能,并给出优雅的构建方式,帮助你一劳永逸地解决这些问题。在提高开发效率的同时,也能提高你的代码质量。
|
||
|
||
接下来,我们先来分析并找出 Go 服务通用的功能。
|
||
|
||
## 构建应用的基础:应用的三大基本功能
|
||
|
||
我们目前见到的 Go 后端服务,基本上可以分为 API 服务和非 API 服务两类。
|
||
|
||
* API 服务:通过对外提供 HTTP/RPC 接口来完成指定的功能。比如订单服务,通过调用创建订单的 API 接口,来创建商品订单。
|
||
* 非 API 服务:通过监听、定时运行等方式,而不是通过 API 调用来完成某些任务。比如数据处理服务,定时从 Redis 中获取数据,处理后存入后端存储中。再比如消息处理服务,监听消息队列(如 NSQ/Kafka/RabbitMQ),收到消息后进行处理。
|
||
|
||
对于 API 服务和非 API 服务来说,它们的启动流程基本一致,都可以分为三步:
|
||
|
||
1. 应用框架的构建,这是最基础的一步。
|
||
2. 应用初始化。
|
||
3. 服务启动。
|
||
|
||
如下图所示:
|
||
|
||
![](https://static001.geekbang.org/resource/image/2a/9e/2a4ef770df3df2439ae595ef301e4d9e.png?wh=3075x1819)
|
||
|
||
图中,命令行程序、命令行参数解析和配置文件解析,是所有服务都需要具备的功能,这些功能有机结合到一起,共同构成了应用框架。
|
||
|
||
所以,我们要构建的任何一个应用程序,至少要具备命令行程序、命令行参数解析和配置文件解析这 3 种功能。
|
||
|
||
* **命令行程序**:用来启动一个应用。命令行程序需要实现诸如应用描述、help、参数校验等功能。根据需要,还可以实现命令自动补全、打印命令行参数等高级功能。
|
||
* **命令行参数解析**:用来在启动时指定应用程序的命令行参数,以控制应用的行为。
|
||
* **配置文件解析**:用来解析不同格式的配置文件。
|
||
|
||
另外,上述 3 类功能跟业务关系不大,可以抽象成一个统一的框架。应用初始化、创建 API/非 API 服务、启动服务,跟业务联系比较紧密,难以抽象成一个统一的框架。
|
||
|
||
## iam-apiserver 是如何构建应用框架的?
|
||
|
||
这里,我通过讲解 iam-apiserver 的应用构建方式,来给你讲解下如何构建应用。iam-apiserver 程序的 main 函数位于 [apiserver.go](https://github.com/marmotedu/iam/blob/master/cmd/iam-apiserver/apiserver.go) 文件中,其构建代码可以简化为:
|
||
|
||
```
|
||
import (
|
||
...
|
||
"github.com/marmotedu/iam/internal/apiserver"
|
||
"github.com/marmotedu/iam/pkg/app"
|
||
)
|
||
|
||
func main() {
|
||
...
|
||
apiserver.NewApp("iam-apiserver").Run()
|
||
}
|
||
|
||
const commandDesc = `The IAM API server validates and configures data ...`
|
||
|
||
// NewApp creates a App object with default parameters.
|
||
func NewApp(basename string) *app.App {
|
||
opts := options.NewOptions()
|
||
application := app.NewApp("IAM API Server",
|
||
basename,
|
||
app.WithOptions(opts),
|
||
app.WithDescription(commandDesc),
|
||
app.WithDefaultValidArgs(),
|
||
app.WithRunFunc(run(opts)),
|
||
)
|
||
|
||
return application
|
||
}
|
||
|
||
func run(opts *options.Options) app.RunFunc {
|
||
return func(basename string) error {
|
||
log.Init(opts.Log)
|
||
defer log.Flush()
|
||
|
||
cfg, err := config.CreateConfigFromOptions(opts)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
return Run(cfg)
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
可以看到,我们是通过调用包 `github.com/marmotedu/iam/pkg/app` 来构建应用的。也就是说,我们将构建应用的功能抽象成了一个 Go 包,通过 Go 包可以提高代码的封装性和复用性。iam-authz-server 和 iam-pump 组件也都是通过 `github.com/marmotedu/iam/pkg/app` 来构建应用的。
|
||
|
||
构建应用的流程也很简单,只需要创建一个 application 实例即可:
|
||
|
||
```
|
||
opts := options.NewOptions()
|
||
application := app.NewApp("IAM API Server",
|
||
basename,
|
||
app.WithOptions(opts),
|
||
app.WithDescription(commandDesc),
|
||
app.WithDefaultValidArgs(),
|
||
app.WithRunFunc(run(opts)),
|
||
)
|
||
|
||
```
|
||
|
||
在创建应用实例时,我传入了下面这些参数。
|
||
|
||
* IAM API Server:应用的简短描述。
|
||
* basename:应用的二进制文件名。
|
||
* opts:应用的命令行选项。
|
||
* commandDesc:应用的详细描述。
|
||
* run(opts):应用的启动函数,初始化应用,并最终启动 HTTP 和 GRPC Web 服务。
|
||
|
||
创建应用时,你还可以根据需要来配置应用实例,比如 iam-apiserver 组件在创建应用时,指定了 `WithDefaultValidArgs` 来校验命令行非选项参数的默认校验逻辑。
|
||
|
||
可以看到,iam-apiserver 通过简单的几行代码,就创建出了一个应用。之所以这么方便,是因为应用框架的构建代码都封装在了 `github.com/marmotedu/iam/pkg/app` 包中。接下来,我们来重点看下 `github.com/marmotedu/iam/pkg/app` 包是如何实现的。为了方便描述,我在下文中统称为 App 包。
|
||
|
||
## App 包设计和实现
|
||
|
||
我们先来看下 App 包目录下的文件:
|
||
|
||
```
|
||
[colin@dev iam]$ ls pkg/app/
|
||
app.go cmd.go config.go doc.go flag.go help.go options.go
|
||
|
||
```
|
||
|
||
`pkg/app` 目录下的 5 个主要文件是 app.go、cmd.go、config.go、flag.go、options.go,分别实现了应用程序框架中的应用、命令行程序、命令行参数解析、配置文件解析和命令行选项 5 个部分,具体关系如下图所示:
|
||
|
||
![](https://static001.geekbang.org/resource/image/ea/12/ea3a0e20854b23fce7a654ce5f207512.png?wh=2378x828)
|
||
|
||
我再来解释下这张图。应用由命令行程序、命令行参数解析、配置文件解析三部分组成,命令行参数解析功能通过命令行选项来构建,二者通过接口解耦合:
|
||
|
||
```
|
||
type CliOptions interface {
|
||
// AddFlags adds flags to the specified FlagSet object.
|
||
// AddFlags(fs *pflag.FlagSet)
|
||
Flags() (fss cliflag.NamedFlagSets)
|
||
Validate() []error
|
||
}
|
||
|
||
```
|
||
|
||
通过接口,应用可以定制自己独有的命令行参数。接下来,我们再来看下如何具体构建应用的每一部分。
|
||
|
||
### 第 1 步:构建应用
|
||
|
||
APP 包提供了 [NewApp](https://github.com/marmotedu/iam/blob/v1.0.4/pkg/app/app.go#L157) 函数来创建一个应用:
|
||
|
||
```
|
||
func NewApp(name string, basename string, opts ...Option) *App {
|
||
a := &App{
|
||
name: name,
|
||
basename: basename,
|
||
}
|
||
|
||
for _, o := range opts {
|
||
o(a)
|
||
}
|
||
|
||
a.buildCommand()
|
||
|
||
return a
|
||
}
|
||
|
||
```
|
||
|
||
NewApp 中使用了设计模式中的选项模式,来动态地配置 APP,支持 WithRunFunc、WithDescription、WithValidArgs 等选项。
|
||
|
||
### 第 2 步:命令行程序构建
|
||
|
||
这一步,我们会使用 Cobra 包来构建应用的命令行程序。
|
||
|
||
NewApp 最终会调用 [buildCommand](https://github.com/marmotedu/iam/blob/v1.0.4/pkg/app/app.go#L172) 方法来创建 Cobra Command 类型的命令,命令的功能通过指定 Cobra Command 类型的各个字段来实现。通常可以指定:Use、Short、Long、SilenceUsage、SilenceErrors、RunE、Args 等字段。
|
||
|
||
在 buildCommand 函数中,也会根据应用的设置添加不同的命令行参数,例如:
|
||
|
||
```
|
||
if !a.noConfig {
|
||
addConfigFlag(a.basename, namedFlagSets.FlagSet("global"))
|
||
}
|
||
|
||
```
|
||
|
||
上述代码的意思是:如果我们设置了 `noConfig=false`,那么就会在命令行参数 global 分组中添加以下命令行选项:
|
||
|
||
```
|
||
-c, --config FILE Read configuration from specified FILE, support JSON, TOML, YAML, HCL, or Java properties formats.
|
||
|
||
```
|
||
|
||
为了更加易用和人性化,命令还具有如下 3 个功能。
|
||
|
||
* 帮助信息:执行 `-h/--help` 时,输出的帮助信息。通过 `cmd.SetHelpFunc` 函数可以指定帮助信息。
|
||
* 使用信息(可选):当用户提供无效的标志或命令时,向用户显示“使用信息”。通过 `cmd.SetUsageFunc` 函数,可以指定使用信息。如果不想每次输错命令打印一大堆 usage 信息,你可以通过设置 `SilenceUsage: true` 来关闭掉 usage。
|
||
* 版本信息:打印应用的版本。知道应用的版本号,对故障排查非常有帮助。通过 `verflag.AddFlags` 可以指定版本信息。例如,App 包通过 `github.com/marmotedu/component-base/pkg/version` 指定了以下版本信息:
|
||
|
||
```
|
||
$ ./iam-apiserver --version
|
||
gitVersion: v0.3.0
|
||
gitCommit: ccc31e292f66e6bad94efb1406b5ced84e64675c
|
||
gitTreeState: dirty
|
||
buildDate: 2020-12-17T12:24:37Z
|
||
goVersion: go1.15.1
|
||
compiler: gc
|
||
platform: linux/amd64
|
||
$ ./iam-apiserver --version=raw
|
||
version.Info{GitVersion:"v0.3.0", GitCommit:"ccc31e292f66e6bad94efb1406b5ced84e64675c", GitTreeState:"dirty", BuildDate:"2020-12-17T12:24:37Z", GoVersion:"go1.15.1", Compiler:"gc", Platform:"linux/amd64"}
|
||
|
||
```
|
||
|
||
接下来,再来看下应用需要实现的另外一个重要功能,也就是命令行参数解析。
|
||
|
||
### 第 3 步:命令行参数解析
|
||
|
||
App 包在构建应用和执行应用两个阶段来实现命令行参数解析。
|
||
|
||
**我们先看构建应用这个阶段。**App 包在 [buildCommand](https://github.com/marmotedu/iam/blob/v1.0.4/pkg/app/app.go#L172) 方法中通过以下代码段,给应用添加了命令行参数:
|
||
|
||
```
|
||
var namedFlagSets cliflag.NamedFlagSets
|
||
if a.options != nil {
|
||
namedFlagSets = a.options.Flags()
|
||
fs := cmd.Flags()
|
||
for _, f := range namedFlagSets.FlagSets {
|
||
fs.AddFlagSet(f)
|
||
}
|
||
|
||
...
|
||
}
|
||
|
||
if !a.noVersion {
|
||
verflag.AddFlags(namedFlagSets.FlagSet("global"))
|
||
}
|
||
if !a.noConfig {
|
||
addConfigFlag(a.basename, namedFlagSets.FlagSet("global"))
|
||
}
|
||
globalflag.AddGlobalFlags(namedFlagSets.FlagSet("global"), cmd.Name())
|
||
|
||
```
|
||
|
||
namedFlagSets 中引用了 Pflag 包,上述代码先通过 `a.options.Flags()` 创建并返回了一批 FlagSet,`a.options.Flags()` 函数会将 FlagSet 进行分组。通过一个 for 循环,将 namedFlagSets 中保存的 FlagSet 添加到 Cobra 应用框架中的 FlagSet 中。
|
||
|
||
buildCommand 还会根据应用的配置,选择性添加一些 flag。例如,在 global 分组下添加 `--version` 和 `--config` 选项。
|
||
|
||
执行 `-h` 打印命令行参数如下:
|
||
|
||
```
|
||
..
|
||
|
||
Usage:
|
||
iam-apiserver [flags]
|
||
|
||
Generic flags:
|
||
|
||
--server.healthz Add self readiness check and install /healthz router. (default true)
|
||
--server.max-ping-count int The max number of ping attempts when server failed to startup. (default 3)
|
||
|
||
...
|
||
|
||
Global flags:
|
||
|
||
-h, --help help for iam-apiserver
|
||
--version version[=true] Print version information and quit.
|
||
|
||
```
|
||
|
||
这里有两个技巧,你可以借鉴。
|
||
|
||
第一个技巧,**将 flag 分组**。
|
||
|
||
一个大型系统,可能会有很多个 flag,例如 kube-apiserver 就有 200 多个 flag,这时对 flag 分组就很有必要了。通过分组,我们可以很快地定位到需要的分组及该分组具有的标志。例如,我们想了解 MySQL 有哪些标志,可以找到 MySQL 分组:
|
||
|
||
```
|
||
Mysql flags:
|
||
|
||
--mysql.database string
|
||
Database name for the server to use.
|
||
--mysql.host string
|
||
MySQL service host address. If left blank, the following related mysql options will be ignored. (default "127.0.0.1:3306")
|
||
--mysql.log-mode int
|
||
Specify gorm log level. (default 1)
|
||
...
|
||
|
||
```
|
||
|
||
第二个技巧,**flag 的名字带有层级关系**。这样不仅可以知道该 flag 属于哪个分组,而且能够避免重名。例如:
|
||
|
||
```
|
||
$ ./iam-apiserver -h |grep host
|
||
--mysql.host string MySQL service host address. If left blank, the following related mysql options will be ignored. (default "127.0.0.1:3306")
|
||
--redis.host string Hostname of your Redis server. (default "127.0.0.1")
|
||
|
||
```
|
||
|
||
对于 MySQL 和 Redis, 都可以指定相同的 host 标志,通过 `--mysql.host` 也可以知道该 flag 隶属于 mysql 分组,代表的是 MySQL 的 host。
|
||
|
||
**我们再看应用执行阶段。**这时会通过 viper.Unmarshal,将配置 Unmarshal 到 Options 变量中。这样我们就可以使用 Options 变量中的值,来执行后面的业务逻辑。
|
||
|
||
我们传入的 Options 是一个实现了 [CliOptions](https://github.com/marmotedu/iam/blob/v1.0.4/pkg/app/options.go#L13) 接口的结构体变量,CliOptions 接口定义为:
|
||
|
||
```
|
||
type CliOptions interface {
|
||
Flags() (fss cliflag.NamedFlagSets)
|
||
Validate() []error
|
||
}
|
||
|
||
```
|
||
|
||
因为 Options 实现了 Validate 方法,所以我们就可以在应用框架中调用 Validate 方法来校验参数是否合法。另外,我们还可以通过以下代码,来判断选项是否可补全和打印:如果可以补全,则补全选项;如果可以打印,则打印选项的内容。实现代码如下:
|
||
|
||
```
|
||
func (a *App) applyOptionRules() error {
|
||
if completeableOptions, ok := a.options.(CompleteableOptions); ok {
|
||
if err := completeableOptions.Complete(); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
if errs := a.options.Validate(); len(errs) != 0 {
|
||
return errors.NewAggregate(errs)
|
||
}
|
||
|
||
if printableOptions, ok := a.options.(PrintableOptions); ok && !a.silence {
|
||
log.Infof("%v Config: `%s`", progressMessage, printableOptions.String())
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
```
|
||
|
||
通过配置补全,可以确保一些重要的配置项具有默认值,当这些配置项没有被配置时,程序也仍然能够正常启动。一个大型项目,有很多配置项,我们不可能对每一个配置项都进行配置。所以,给重要配置项设置默认值,就显得很重要了。
|
||
|
||
这里,我们来看下 iam-apiserver 提供的 Validate 方法:
|
||
|
||
```
|
||
func (s *ServerRunOptions) Validate() []error {
|
||
var errs []error
|
||
|
||
errs = append(errs, s.GenericServerRunOptions.Validate()...)
|
||
errs = append(errs, s.GrpcOptions.Validate()...)
|
||
errs = append(errs, s.InsecureServing.Validate()...)
|
||
errs = append(errs, s.SecureServing.Validate()...)
|
||
errs = append(errs, s.MySQLOptions.Validate()...)
|
||
errs = append(errs, s.RedisOptions.Validate()...)
|
||
errs = append(errs, s.JwtOptions.Validate()...)
|
||
errs = append(errs, s.Log.Validate()...)
|
||
errs = append(errs, s.FeatureOptions.Validate()...)
|
||
|
||
return errs
|
||
}
|
||
|
||
```
|
||
|
||
可以看到,每个配置分组,都实现了 `Validate()` 函数,对自己负责的配置进行校验。通过这种方式,程序会更加清晰。因为只有配置提供者才更清楚如何校验自己的配置项,所以最好的做法是将配置的校验放权给配置提供者(分组)。
|
||
|
||
### 第 4 步:配置文件解析
|
||
|
||
在 buildCommand 函数中,通过addConfigFlag调用,添加了 `-c, --config FILE` 命令行参数,用来指定配置文件:
|
||
|
||
```
|
||
addConfigFlag(a.basename, namedFlagSets.FlagSet("global"))
|
||
|
||
```
|
||
|
||
addConfigFlag函数代码如下:
|
||
|
||
```
|
||
func addConfigFlag(basename string, fs *pflag.FlagSet) {
|
||
fs.AddFlag(pflag.Lookup(configFlagName))
|
||
|
||
viper.AutomaticEnv()
|
||
viper.SetEnvPrefix(strings.Replace(strings.ToUpper(basename), "-", "_", -1))
|
||
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_"))
|
||
|
||
cobra.OnInitialize(func() {
|
||
if cfgFile != "" {
|
||
viper.SetConfigFile(cfgFile)
|
||
} else {
|
||
viper.AddConfigPath(".")
|
||
|
||
if names := strings.Split(basename, "-"); len(names) > 1 {
|
||
viper.AddConfigPath(filepath.Join(homedir.HomeDir(), "."+names[0]))
|
||
}
|
||
|
||
viper.SetConfigName(basename)
|
||
}
|
||
|
||
if err := viper.ReadInConfig(); err != nil {
|
||
_, _ = fmt.Fprintf(os.Stderr, "Error: failed to read configuration file(%s): %v\n", cfgFile, err)
|
||
os.Exit(1)
|
||
}
|
||
})
|
||
}
|
||
|
||
```
|
||
|
||
addConfigFlag 函数中,指定了 Cobra Command 在执行命令之前,需要做的初始化工作:
|
||
|
||
```
|
||
func() {
|
||
if cfgFile != "" {
|
||
viper.SetConfigFile(cfgFile)
|
||
} else {
|
||
viper.AddConfigPath(".")
|
||
|
||
if names := strings.Split(basename, "-"); len(names) > 1 {
|
||
viper.AddConfigPath(filepath.Join(homedir.HomeDir(), "."+names[0]))
|
||
}
|
||
|
||
viper.SetConfigName(basename)
|
||
}
|
||
|
||
if err := viper.ReadInConfig(); err != nil {
|
||
_, _ = fmt.Fprintf(os.Stderr, "Error: failed to read configuration file(%s): %v\n", cfgFile, err)
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
上述代码实现了以下功能:
|
||
|
||
* 如果命令行参数中没有指定配置文件的路径,则加载默认路径下的配置文件,通过 viper.AddConfigPath、viper.SetConfigName 来设置配置文件搜索路径和配置文件名。通过设置默认的配置文件,可以使我们不用携带任何命令行参数,即可运行程序。
|
||
* 支持环境变量,通过 viper.SetEnvPrefix 来设置环境变量前缀,避免跟系统中的环境变量重名。通过 viper.SetEnvKeyReplacer 重写了 Env 键。
|
||
|
||
上面,我们给应用添加了配置文件的命令行参数,并设置在命令执行前,读取配置文件。在命令执行时,会将配置文件中的配置项和命令行参数绑定,并将 Viper 的配置 Unmarshal 到传入的 Options 中:
|
||
|
||
```
|
||
if !a.noConfig {
|
||
if err := viper.BindPFlags(cmd.Flags()); err != nil {
|
||
return err
|
||
}
|
||
|
||
if err := viper.Unmarshal(a.options); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
Viper 的配置是命令行参数和配置文件配置 merge 后的配置。如果在配置文件中指定了 MySQL 的 host 配置,并且也同时指定了 `--mysql.host` 参数,则会优先取命令行参数设置的值。这里需要注意的是,不同于 YAML 格式的分级方式,配置项是通过点号 `.` 来分级的。
|
||
|
||
至此,我们已经成功构建了一个优秀的应用框架,接下来我们看下这个应用框架具有哪些优点吧。
|
||
|
||
## 这样构建的应用程序,有哪些优秀特性?
|
||
|
||
借助 Cobra 自带的能力,构建出的应用天然具备帮助信息、使用信息、子命令、子命令自动补全、非选项参数校验、命令别名、PreRun、PostRun 等功能,这些功能对于一个应用来说是非常有用的。
|
||
|
||
Cobra 可以集成 Pflag,通过将创建的 Pflag FlagSet 绑定到 Cobra 命令的 FlagSet 中,使得 Pflag 支持的标志能直接集成到 Cobra 命令中。集成到命令中有很多好处,例如:`cobra -h` 可以打印出所有设置的 flag,Cobra Command 命令提供的 GenBashCompletion 方法,可以实现命令行选项的自动补全。
|
||
|
||
通过 viper.BindPFlags 和 viper.ReadInConfig 函数,可以统一配置文件、命令行参数的配置项,使得应用的配置项更加清晰好记。面对不同场景可以选择不同的配置方式,使配置更加灵活。例如:配置 HTTPS 的绑定端口,可以通过 `--secure.bind-port` 配置,也可以通过配置文件配置(命令行参数优先于配置文件):
|
||
|
||
```
|
||
secure:
|
||
bind-port: 8080
|
||
|
||
```
|
||
|
||
可以通过 `viper.GetString("secure.bind-port")` 这类方式获取应用的配置,获取方式更加灵活,而且全局可用。
|
||
|
||
将应用框架的构建方法实现成了一个 Go 包,通过 Go 包可以提高应用构建代码的封装性和复用性。
|
||
|
||
## 如果你想自己构建应用,需要注意些什么?
|
||
|
||
当然,你也可以使用其他方式构建你的应用程序。比如,我就见过很多开发者使用如下方式来构建应用:直接在 `main.go` 文件中通过 `gopkg.in/yaml.v3` 包解析配置,通过 Go 标准库的 flag 包简单地添加一些命令行参数,例如 `--help`、`--config`、`--version`。
|
||
|
||
但是,在你自己独立构建应用程序时,很可能会踩这么 3 个坑:
|
||
|
||
* 构建的应用功能简单,扩展性差,导致后期扩展复杂。
|
||
* 构建的应用没有帮助信息和使用信息,或者信息格式杂乱,增加应用的使用难度。
|
||
* 命令行选项和配置文件支持的配置项相互独立,导致配合应用程序的时候,不知道该使用哪种方式来配置。
|
||
|
||
在我看来,对于小的应用,自己根据需要构建没什么问题,但是对于一个大型项目的话,还是在应用开发之初,就采用一些功能多、扩展性强的优秀包。这样,以后随着应用的迭代,可以零成本地进行功能添加和扩展,同时也能体现我们的专业性和技术深度,提高代码质量。
|
||
|
||
如果你有特殊需求,一定要自己构建应用框架,那么我有以下几个建议:
|
||
|
||
* 应用框架应该清晰易读、扩展性强。
|
||
* 应用程序应该至少支持如下命令行选项:`-h` 打印帮助信息;`-v` 打印应用程序的版本;`-c` 支持指定配置文件的路径。
|
||
* 如果你的应用有很多命令行选项,那么建议支持 `--secure.bind-port` 这样的长选项,通过选项名字,就可以知道选项的作用。
|
||
* 配置文件使用 `yaml` 格式,`yaml` 格式的配置文件,能支持复杂的配置,还清晰易读。
|
||
* 如果你有多个服务,那么要保持所有服务的应用构建方式是一致的。
|
||
|
||
## 总结
|
||
|
||
一个应用框架由命令、命令行参数解析、配置文件解析 3 部分功能组成,我们可以通过 Cobra 来构建命令,通过 Pflag 来解析命令行参数,通过 Viper 来解析配置文件。一个项目,可能包含多个应用,这些应用都需要通过 Cobra、Viper、Pflag 来构建。为了不重复造轮子,简化应用的构建,我们可以将这些功能实现为一个 Go 包,方便直接调用构建应用。
|
||
|
||
IAM 项目的应用都是通过 `github.com/marmotedu/iam/pkg/app` 包来构建的,在构建时,调用 App 包提供的 NewApp 函数,来构建一个应用:
|
||
|
||
```
|
||
func NewApp(basename string) *app.App {
|
||
opts := options.NewOptions()
|
||
application := app.NewApp("IAM API Server",
|
||
basename,
|
||
app.WithOptions(opts),
|
||
app.WithDescription(commandDesc),
|
||
app.WithDefaultValidArgs(),
|
||
app.WithRunFunc(run(opts)),
|
||
)
|
||
|
||
return application
|
||
}
|
||
|
||
```
|
||
|
||
在构建应用时,只需要提供应用简短/详细描述、应用二进制文件名称和命令行选项即可。App 包会根据 Options 提供的 `Flags()` 方法,来给应用添加命令行选项。命令行选项中提供了 `-c, --config` 选项来指定配置文件,App 包也会加载并解析这个配置文件,并将配置文件和命令行选项相同配置项进行 Merge,最终将配置项的值保存在传入的 Options 变量中,供业务代码使用。
|
||
|
||
最后,如果你想自己构建应用,我给出了一些我的建议:设计一个清晰易读、易扩展的应用框架;支持一些常见的选项,例如 `-h`, `-v`, `-c` 等;如果应用的命令行选项比较多,建议使用 `--secure.bind-port` 这样的长选项。
|
||
|
||
## 课后练习
|
||
|
||
1. 除了 Cobra、Viper、Pflag 之外,你还遇到过哪些比较优秀的包或者工具,可以用来构建应用框架?欢迎在留言区分享。
|
||
2. 研究下 iam-apiserver 的命令行选项 [Options](https://github.com/marmotedu/iam/blob/v1.0.4/internal/apiserver/options/options.go) 是如何通过 Options 的 Flags()方法来实现 Flag 分组的,并思考下这样做有什么好处。
|
||
|
||
欢迎你在留言区与我交流讨论。当然了,你也可以把这一讲分享给你身边的朋友,他们的一些想法或许会让你有更大的收获。我们下一讲见!
|