Dwi Wahyudi
Senior Software Engineer (Ruby, Golang, Java)
In this article, we’re going to build a Golang web application, but this time with the help of Cobra, Viper and DB migration.
Overview
In this article, we’re going to leverage some popular tools for enhancing our web application, notably Cobra, Viper and DB migration tool.
- Cobra is a library for building command line applications, our web app is one of them. https://github.com/spf13/cobra
- Viper is a library for managing configurations and secrets. https://github.com/spf13/viper
- As for database migration, we’re going to use this: https://github.com/golang-migrate/migrate.
- Additionally, for unit test, testify is a nice library that can help us with assertions and mocks: https://github.com/stretchr/testify.
Initializing the Application
Let’s start by initializing a new Golang app, and install some dependencies/libraries. The web app will serve a http request that will return a json response, the json values will be fetched from the database (MySQL 8).
mkdir updated-golang-api
cd updated-golang-api
go mod init updated-golang-api
go get -u github.com/spf13/cobra
go get -u github.com/spf13/viper
go get -u github.com/golang-migrate/migrate
Root And Server Commands
Let’s now create cmd directory (cmd package), where we will place a root.go file, but before we go to that file, let’s create a new go file under cmd/server (server package), we’ll give it a name server.go, this will contain command configurations for our web server:
var ServerCmd = &cobra.Command{
Use: "server",
Short: "A very simple web app server",
Long: `A sample restful web app communicating with mysql db.`,
Run: func(cmd *cobra.Command, args []string) {
launchRestServer()
},
}
func Execute() {
if err := ServerCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func launchRestServer() {
log.Println("Running the web server")
http.HandleFunc("/ping", func(w http.ResponseWriter, req *http.Request) {
fmt.Fprintf(w, "pong\n")
})
http.ListenAndServe(":8080", nil)
}
Nothing really special, except for the &cobra.Command struct, it contains some fields, Use, Short, Long and Run.
Useis the command we will use in command line later. This means we will run the server withgo run main.go server.Shortis short description of the command. This will be shown in help output.Longis long description of the command. This will be shown when we runhelp <command>.Runis the function we want to run for the command. In above codeRuncallslaunchRestServer()which in turn will create a simple web server.
There are other fields like Version, Args, Aliases, etc that we can use on that struct.
Every command will need to have Execute() function, this is the convention used by Cobra library. This is important for registering this command. Registered command will be executed by calling this function, which in turns call the function of Run field. After registering the command, we can use it with the value of Use field. In this case, Use field above is server. When we use server we will call launchRestServer(). In other words, running go run main.go server will indirectly call launchRestServer().
Now let’s deal with the cmd/root.go, we will add the server cmd above. Make sure that ServerCmd on server package above is public, so we can add it here:
import (
// ... other imports
"updated-golang-api/cmd/server"
)
var rootCmd = &cobra.Command{
Short: "A very simple web app",
Long: `A very simple web app, it has server and db migration`,
}
func Execute() {
rootCmd.AddCommand(server.ServerCmd)
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
Above command code has no Run field on the cobra.Command struct, this means go run main.go (root command) will not do anything other than printing some information message about the app.
AddCommand() function above, registers the server command to the root command.
With these setups in place, when we want to start the server, we can just run go run main.go server, or ./updated-golang-api server (when from compiled binary).
Main Function
The main function should be small, it calls the rootCmd above.
package main
import "updated-golang-api/cmd"
func main() {
cmd.Execute()
}
From here, let’s run the server with either go build && ./updated-golang-api server or go run main.go server. We can then visit localhost:8080/ping to check if our server runs correctly.
As stated above running the app without server, in other words: the root command, will just print out standard CLI information message (this is nicely provided by Cobra by default).
$ go run main.go
A very simple web app, it has server and db migration
Usage:
[command]
Available Commands:
help Help about any command
server A very simple web app server
Flags:
-h, --help help for this command
Use " [command] --help" for more information about a command.
As we can see above, Cobra gives us nice information regarding the application including the registered commands.
If we want it (the root command) to do something, we’ll need to fill Run field with a function for the rootCmd cobra.Command struct, just like we did for the server.
Configurations and Secrets
Before we go to database migrations, let’s prepare the configurations first using Viper. These configurations are needed ultimately for database connection. Let’s create the config directory, this will contain config.go which will read configurations/secrets from config/.env (make sure to put this in .gitignore as well).
Here’s how the .env file would look like, this setup should match with our MySQL server setup in the next section.
DBHOST=localhost
DBPORT=3306
DBUSERNAME=root
DBPASSWORD=chocolatecake
DBNAME=sampleapp
Now, onto the Golang code to read this .env file. Viper is very easy to use but offers a lots of awesome features for us to use. It offers us reading configs from json, yaml, or even toml file, but in this article, we’re going to use .env file.
Here’s how the config.go file will look like:
package config
import (
"fmt"
"github.com/spf13/viper"
)
type DBConnection struct {
Host string
Port string
Username string
Password string
DatabaseName string
}
func DBConnectionCfg() DBConnection {
viper.SetConfigFile("config/.env")
err := viper.ReadInConfig()
if err != nil {
panic(fmt.Errorf("Fatal error config file: %s \n", err))
}
return DBConnection{
Host: viper.Get("DBHOST").(string),
Port: viper.Get("DBPORT").(string),
Username: viper.Get("DBUSERNAME").(string),
Password: viper.Get("DBPASSWORD").(string),
DatabaseName: viper.Get("DBNAME").(string),
}
}
This code is straight-forward, we just read the .env file, read the configuration one by one and assign it to the DBConnection struct. We should make the config as lightweight as possible, only pass around the necessary config for each function call.
DB Migration Command
Now, let’s take a look at the db migration. We’re going to use MySQL 8.0.23 in this article. Let’s get it from the Docker repo (and set a root password for it):
docker run -itd --rm -p 3306:3306 -p 33060:33060 -e MYSQL_ROOT_HOST='%' -e MYSQL_ROOT_PASSWORD='chocolatecake' -e MYSQL_DATABASE=sampleapp --name mysql-8.0.23 mysql/mysql-server:8.0.23
Make sure that the password match with the password we specified in config/.env above. This docker command will also create a new database sampleapp.
Then create a new directory migrations/, this is where our database migration files will reside. Let’s create some:
For the up migration, 1_create_user.up.sql:
CREATE TABLE user (
id INT(11) UNSIGNED AUTO_INCREMENT PRIMARY KEY,
firstname VARCHAR(200) NOT NULL,
lastname VARCHAR(200) NOT NULL,
email VARCHAR(200)
);
For the down migration, 1_create_user.down.sql:
DROP TABLE user;
Before we write DB migration command, let’s create db connection code db/conn.go. This will use the DB connection configurations above. It’s very simple:
package db
import (
"database/sql"
"fmt"
"updated-golang-api/config"
)
func Conn(connection config.DBConnection) (*sql.DB, error) {
host := connection.Host
port := connection.Port
username := connection.Username
password := connection.Password
databaseName := connection.DatabaseName
dataSourceName := fmt.Sprintf("%v:%v@tcp(%v:%v)/%v", username, password, host, port, databaseName)
db, err := sql.Open("mysql", dataSourceName)
if err != nil {
return nil, err
}
return db, nil
}
We’ve written our db connection code above, let’s use this for our db migration command.
Creating the DB migration command will be like this:
package migration
import (
"fmt"
"github.com/spf13/cobra"
"log"
"os"
"strconv"
"updated-golang-api/config"
database "updated-golang-api/db"
_ "github.com/go-sql-driver/mysql"
"github.com/golang-migrate/migrate"
"github.com/golang-migrate/migrate/database/mysql"
_ "github.com/golang-migrate/migrate/source/file"
)
var MigrationCmd = &cobra.Command{
Use: "migrations",
Short: "DB Migration for the app",
Long: `DB Migration for the app.`,
Run: func(cmd *cobra.Command, args []string) {
launchDBMigration(args)
},
}
func Execute() {
if err := MigrationCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func launchDBMigration(args []string) {
db, err := database.Conn(config.DBConnectionCfg())
if err != nil {
log.Panic("cannot open db connection:", err)
}
driver, err := mysql.WithInstance(db, &mysql.Config{})
if err != nil {
log.Panic("cannot setup driver:", err)
}
m, err := migrate.NewWithDatabaseInstance(
"file://migrations",
"mysql",
driver,
)
if err != nil {
log.Panic("cannot setup migrations:", err)
}
var steps int
if len(args) > 0 {
stepArg := args[0]
steps, err = strconv.Atoi(stepArg)
if err != nil {
log.Panic("invalid arg for migrations:", err)
}
} else {
steps = 99999
}
m.Steps(steps)
}
Some explanations about this command:
- This command will use
migrations, which means, in order to run db migrations, we need to rungo run main.go migrations. - This command will call
launchDBMigrationalong with theargsfrom the command line. This is needed for db migrations steps. Steps here means: how many migrations scripts we want to run. We only have 1 so far, so specifying the args with 1 or more is not a problem.- If we want to do up migrations we specify the args to be > 0, ex:
go run main.go migrations 3orgo run.main.go 99. - For down migrations (rolling back), negative number must be specified instead, ex:
go run main.go migrations -- -3orgo run main.go migrations -- -99. - In above code, if we don’t specify the args, we will just do up migrations for all scripts.
- If we want to do up migrations we specify the args to be > 0, ex:
- Inside the
launchDBMigrationfunction, we open the database withConn()function we already wrote before. We then set up the driver, and then tell the db migration library to look for migration scripts inside themigrationsdirectory withNewWithDatabaseInstancefunction. - We then execute the db migration with the specified steps. This is done with
Stepsfunction.
Since we only have 1 migration script so far, we only need 1 step, running go run main.go migrations 1 will be just fine. If somehow we want to roll it back, we can run it with go run main.go migrations -- -1.
If we look into the sampleapp database, there are 2 tables now: user and schema_migrations. schema_migrations table is there to keep track what db migrations is already up, it is used by database migration library to find out which migration script to run. If we roll back a migration, such migration will be deleted from schema_migrations table.
Here’s the code: https://github.com/dwahyudi/updated-golang-api.
In the next part we’re going to implement our web server to serve data from the database. We’re going to write some unit tests for it as well.