23 Application Building Practice How to Construct an Outstanding Enterprise Application Framework

23 Application Building Practice How to Construct an Outstanding Enterprise Application Framework #

Hello, I’m Kong Lingfei. Today, let’s talk about what needs to be done in application development.

Application development is the core work for software engineers. In my 7 years of Go development experience, I have built over 50 backend applications, and I am well aware of the pain points, such as:

  • Reinventing the wheel. The same functionality needs to be developed from scratch each time, which not only wastes a lot of time and energy, but also results in inconsistent code quality.
  • High understanding cost. The same functionality can be implemented in multiple ways across different services. If the functionality needs upgrading or new team members join, it may require re-understanding the implementation multiple times.
  • Heavy development workload for functionality upgrades. An application consists of multiple services. If you need to upgrade a specific functionality, you need to update the code for all services simultaneously.

To solve these problems, a good approach is: identify common functionality, implement it in an elegant way, and provide it to all services in the form of Go packages.

If you are facing these problems and looking for solutions, then you should pay close attention to today’s lecture. I will help you identify common functionalities in services and provide an elegant approach to solve these problems once and for all. This will improve your development efficiency and code quality.

Next, let’s analyze and identify the common functionalities for Go services.

Building the foundation of an application: The three fundamental functions of an application #

The Go backend services we currently see can basically be divided into two categories: API services and non-API services.

  • API services: These services provide designated functionalities by exposing HTTP/RPC interfaces. For example, an order service creates product orders by calling the create order API interface.
  • Non-API services: These services perform certain tasks through listening, running at fixed intervals, etc., instead of through API calls. For example, a data processing service retrieves data from Redis at regular intervals, processes it, and stores it in a backend storage. Another example is a message processing service that listens to message queues (such as NSQ/Kafka/RabbitMQ), receives messages, and processes them.

For both API services and non-API services, their startup processes are essentially the same and can be divided into three steps:

  1. Building the application framework, which is the most fundamental step.
  2. Initializing the application.
  3. Starting the service.

As shown in the figure below:

In the figure, the command-line program, command-line argument parsing, and configuration file parsing are functionalities that all services need to have. These functionalities are combined together to form the application framework.

Therefore, any application we build must have at least these three functionalities: a command-line program, command-line argument parsing, and configuration file parsing.

  • Command-line program: Used to start an application. The command-line program needs to implement functionalities such as application description, help, and parameter validation. Depending on the needs, advanced functionalities such as command auto-completion and printing command-line arguments can also be implemented.
  • Command-line argument parsing: Used to specify command-line arguments for the application when starting it in order to control the behavior of the application.
  • Configuration file parsing: Used to parse configuration files in different formats.

In addition, the aforementioned three types of functionalities are not closely related to the business logic and can be abstracted into a unified framework. On the other hand, application initialization, creating API/non-API services, and starting services are more closely related to the business logic and are difficult to be abstracted into a unified framework.

How is iam-apiserver built as an application framework? #

Here, I will explain how to build an application by explaining the application construction method of iam-apiserver. The main function of the iam-apiserver program is located in the apiserver.go file, and its construction code can be simplified as follows:

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)
    }
}

As you can see, we build the application by calling the package github.com/marmotedu/iam/pkg/app. In other words, we abstract the function of building the application into a Go package, which improves the encapsulation and reusability of the code. The iam-authz-server and iam-pump components also use github.com/marmotedu/iam/pkg/app to build the application.

The process of building the application is also very simple, just create an application instance:

opts := options.NewOptions()
application := app.NewApp("IAM API Server",
    basename,
    app.WithOptions(opts),
    app.WithDescription(commandDesc),
    app.WithDefaultValidArgs(),
    app.WithRunFunc(run(opts)),
)

When creating the application instance, I passed in the following parameters.

  • IAM API Server: A brief description of the application.
  • basename: The binary file name of the application.
  • opts: The command line options of the application.
  • commandDesc: A detailed description of the application.
  • run(opts): The startup function of the application, which initializes the application and eventually starts the HTTP and GRPC Web services.

When creating the application, you can also configure the application instance according to your needs. For example, the iam-apiserver component specifies WithDefaultValidArgs to validate the default validation logic of non-option command line parameters.

As you can see, iam-apiserver creates an application with just a few lines of code. The reason why it is so convenient is that the construction code of the application framework is encapsulated in the github.com/marmotedu/iam/pkg/app package. Next, let’s take a closer look at how the github.com/marmotedu/iam/pkg/app package is implemented. For convenience, I will refer to it as the App package in the following text.

App Package Design and Implementation #

Let’s take a look at the files in the pkg/app/ directory of the App package:

[colin@dev iam]$ ls pkg/app/
app.go  cmd.go  config.go  doc.go  flag.go  help.go  options.go

The five main files in the pkg/app/ directory are app.go, cmd.go, config.go, flag.go, and options.go, which respectively implement the application, command-line program, command-line argument parsing, configuration file parsing, and command-line options in the application framework. The specific relationships are shown in the following diagram:

Let me explain this diagram. The application consists of three parts: the command-line program, command-line argument parsing, and configuration file parsing. The command-line argument parsing functionality is built through command-line options, and the two are decoupled through an interface:

type CliOptions interface {    
    // AddFlags adds flags to the specified FlagSet object.    
    // AddFlags(fs *pflag.FlagSet)    
    Flags() (fss cliflag.NamedFlagSets)    
    Validate() []error    
}

Through this interface, the application can customize its own command-line arguments. Next, let’s take a look at how to construct each part of the application.

Step 1: Build the Application #

The APP package provides the NewApp function to create an application:

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
}

In NewApp, the option pattern in design patterns is used to dynamically configure the APP, supporting options like WithRunFunc, WithDescription, WithValidArgs, etc.

Step 2: Build the Command-line Program #

In this step, we will use the Cobra package to build the command-line program of the application.

NewApp ultimately calls the buildCommand method to create a Cobra Command type command. The functionality of the command is implemented by specifying the various fields of the Cobra Command type. Typically, you can specify fields like Use, Short, Long, SilenceUsage, SilenceErrors, RunE, Args, etc.

In the buildCommand function, different command-line arguments are also added based on the application’s settings, for example:

if !a.noConfig {
    addConfigFlag(a.basename, namedFlagSets.FlagSet("global"))
} 

The meaning of the above code is: if noConfig is set to false, then the following command-line option will be added under the global command-line parameter group:

-c, --config FILE   Read configuration from specified FILE, support JSON, TOML, YAML, HCL, or Java properties formats.

To make it more user-friendly, the command also has the following three features.

  • Help information: When -h/--help is executed, the displayed help information can be specified using the cmd.SetHelpFunc function.
  • Usage information (optional): When users provide invalid flags or commands, “usage information” is displayed to guide the user. The cmd.SetUsageFunc function can be used to specify the usage information. If you don’t want to print a large amount of usage information every time a command is entered incorrectly, you can set SilenceUsage: true to turn off the usage information.
  • Version information: Print the version of the application. Knowing the version number of the application is very helpful for troubleshooting. The version information can be specified using verflag.AddFlags. For example, the App package specifies the version information as follows using 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"}

Next, let’s take a look at another important functionality that the application needs to implement, which is command-line argument parsing.

Step 3: Command-line Argument Parsing #

The App package implements command-line argument parsing in two stages: building the application and executing the application.

Let’s first look at the stage of building the application. In the buildCommand method of the App package, the following code segment is used to add command-line arguments to the application:

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())

In the above code, Pflag is referenced in namedFlagSets. The code first creates and returns a batch of FlagSet through a.options.Flags(). The a.options.Flags() function groups the FlagSets. Then, through a for loop, the FlagSets stored in namedFlagSets are added to the FlagSet in the Cobra application framework.

buildCommand will also selectively add some flags based on the application’s configuration. For example, add the --version and --config options under the global group.

Execute -h to print the command-line arguments as follows:

..

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.

Here are two tips that you can learn from.

The first tip is to group flags.

In a large system, there may be many flags. For example, kube-apiserver has over 200 flags. In such cases, it is necessary to group the flags. Through grouping, we can quickly locate the desired group and the flags it contains. For example, if we want to know which flags MySQL has, we can find the MySQL group:

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

The second tip is to specify the usage information for the command.

cmd.SetUsageFunc

By setting the cmd.SetUsageFunc function, you can specify the usage information. If a user provides invalid flags or commands, this usage information will be displayed. If you don’t want to print a large amount of usage information every time a command is entered incorrectly, you can set SilenceUsage: true to turn off the usage information.

That’s it!

Specify gorm log level. (default 1) #

The second tip is that flags have hierarchical names. This not only allows us to know which group a flag belongs to, but also avoids naming conflicts. For example:

$ ./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")

For MySQL and Redis, the same host flag can be specified, and --mysql.host indicates that this flag belongs to the mysql group, representing the host of MySQL.

Now let’s look at the application execution stage. At this stage, the configuration is unmarshalled into the Options variable through viper.Unmarshal. This allows us to use the values in the Options variable to perform the subsequent business logic.

The Options we pass in is a struct variable that implements the CliOptions interface, which is defined as follows:

type CliOptions interface {
    Flags() (fss cliflag.NamedFlagSets)
    Validate() []error
}

Since Options implements the Validate method, we can call the Validate method in the application framework to validate if the parameters are valid. In addition, we can also determine whether the options can be completed and printed through the following code: if completion is possible, complete the option; if printing is possible, print the content of the option. The implementation code is as follows:

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
}

Through configuration completion, we can ensure that certain important configuration items have default values, so that the program can still start normally even if these configuration items are not configured. In a large project with many configuration items, it is impossible to configure every configuration item. Therefore, it is important to set default values for important configuration items.

Here, let’s take a look at the Validate method provided by iam-apiserver:

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
}

As you can see, each configuration group has implemented the Validate() function to validate its own configuration. This makes the program clearer because only the configuration provider knows how to validate its own configuration items. Therefore, the best practice is to delegate the configuration validation to the configuration provider (group).

Step 4: Configuration File Parsing #

In the buildCommand function, the -c, --config FILE command-line parameter is added by calling addConfigFlag to specify the configuration file:

addConfigFlag(a.basename, namedFlagSets.FlagSet("global"))

The addConfigFlag function is defined as follows:

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)
        }
    })
}

In the addConfigFlag function, the initialization work that the Cobra Command needs to do before executing the command is specified:

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)
    }
})

The above code implements the following features:

  • If the path to the configuration file is not specified in the command-line parameters, the configuration file in the default path is loaded by setting the configuration file search path and the configuration file name through viper.AddConfigPath and viper.SetConfigName. By setting the default configuration file, we can run the program without carrying any command-line parameters.
  • Supports environment variables by setting the environment variable prefix through viper.SetEnvPrefix to avoid conflicts with environment variables in the system. viper.SetEnvKeyReplacer has been used to rewrite the Env key.

Above, we added the command-line parameter for the configuration file to the application, and set it to read the configuration file before executing the command. When the command is executed, the configuration items in the configuration file are bound to the command-line parameters, and Viper’s configuration is unmarshalled into the Options passed in:

if !a.noConfig {
    if err := viper.BindPFlags(cmd.Flags()); err != nil {
        return err
    }

    if err := viper.Unmarshal(a.options); err != nil {
        return err
    }
}

Viper’s configuration is the merged configuration of the command-line parameters and the configuration file. If the host configuration of MySQL is specified in the configuration file and the --mysql.host parameter is also specified, the value set by the command-line parameter will take precedence. It is important to note that unlike the hierarchical structure of YAML format, configuration items are hierarchically divided using dot ..

So far, we have successfully built an excellent application framework. Now let’s see what advantages this application framework has.

What are the outstanding features of an application built in this way? #

With the help of Cobra’s built-in capabilities, applications built using Cobra naturally have features such as help information, usage information, subcommands, automatic completion of subcommands, non-option parameter validation, command aliases, PreRun, and PostRun, which are very useful for an application.

Cobra can integrate with Pflag by binding the created Pflag FlagSet to the FlagSet of Cobra commands, enabling the integration of flags supported by Pflag directly into Cobra commands. There are many benefits to integrating them into commands, for example: cobra -h can print out all the flags set, and the GenBashCompletion method provided by Cobra Command can achieve automatic completion of command line options.

By using the viper.BindPFlags and viper.ReadInConfig functions, the configuration options can be unified for both configuration files and command line parameters, making the application’s configuration options more clear and easy to remember. Different configuration methods can be chosen for different scenarios, making the configuration more flexible. For example, to configure the bind port for HTTPS, it can be done through --secure.bind-port or through a configuration file (command line parameters take priority over configuration files):

secure:    
    bind-port: 8080

One can obtain the application’s configuration using methods like viper.GetString("secure.bind-port"), which provides a more flexible way of retrieving the configuration and is globally accessible.

The method of building the application framework has been implemented as a Go package, which improves the encapsulation and reusability of the application building code.

What should you pay attention to if you want to build an application yourself? #

Of course, there are other ways to build your application. For example, I have seen many developers build applications directly in the main.go file using packages such as gopkg.in/yaml.v3 to parse configurations and the flag package from the Go standard library to add simple command line parameters such as --help, --config, --version.

However, when you build an application independently, you may encounter the following three pitfalls:

  • The functionality of the built application is simple, with poor extensibility, which leads to complexity when expanding in the future.
  • The built application lacks help and usage information, or the information format is messy, increasing the difficulty of using the application.
  • Command line options and configuration file supported settings are independent of each other, causing confusion on which way to use when configuring the application.

In my opinion, for small applications, there is no problem building them according to your needs, but for a large project, it is still advisable to use excellent packages with rich functionality and strong extensibility at the beginning of application development. This way, in the future iterations of the application, you can add and extend functionality at zero cost, while also demonstrating our professionalism and technical depth, and improving code quality.

If you have special requirements and must build your own application framework, I have the following suggestions:

  • The application framework should be clear, readable, and have strong extensibility.
  • The application should support at least the following command line options: -h to print help information; -v to print the version of the application; -c to specify the path of the configuration file.
  • If your application has many command line options, it is recommended to support long options such as --secure.bind-port, so that the purpose of the option can be known by its name.
  • Use yaml format for the configuration file. yaml format supports complex configuration and is clear and readable.
  • If you have multiple services, make sure that the application build process is consistent for all services.

Summary #

An application framework consists of three parts: commands, command-line argument parsing, and configuration file parsing. We can use Cobra to build commands, Pflag to parse command-line arguments, and Viper to parse configuration files. A project may contain multiple applications, all of which need to be built using Cobra, Viper, and Pflag. In order to avoid reinventing the wheel and simplify application development, we can implement these functionalities as a Go package for easy and direct use in building applications.

Applications in the IAM project are built using the github.com/marmotedu/iam/pkg/app package. When building an application, we call the NewApp function provided by the App package to create an application:

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
}

When building an application, we only need to provide a brief/detailed description of the application, the name of the binary file, and command-line options. The App package adds the command-line options to the application based on the Flags() method provided by Options. The command-line options include the -c, --config option for specifying a configuration file. The App package also loads and parses this configuration file, merges the configuration items with those specified in the command-line options, and finally saves the values of the configuration items in the provided Options variable for use by the business logic.

Lastly, if you want to build your own application, I have some suggestions: design a clear, readable, and extensible application framework; support common options such as -h, -v, and -c; if there are many command-line options for the application, it is recommended to use long options like --secure.bind-port.

Post-class Exercise #

  1. Apart from Cobra, Viper, and Pflag, what other excellent packages or tools have you encountered that can be used to build application frameworks? Feel free to share in the comments section.
  2. Study how the command-line options of iam-apiserver are implemented through the Flags() method in Options, and ponder the benefits of doing so.

Feel free to communicate and discuss with me in the comments section. Of course, you can also share this lecture with your friends around you. Their ideas may bring you even greater gains. See you in the next lecture!