2024-11-19 A Simple Note Taker CLI App in Golang

I'd like to build a small CLI app in Golang that provides basic CRUD operations on SQL databases. I'll use note taking as the starting point, as it is generic, contains one-to-many and many-to-many relations between data models, and can serve as the basis for other projects.

Setting up

To set up the project:

mkdir noter
cd noter
go mod init jyrobin/noter
I skip the introduction to Golang and its installation to make this port shorter. You can search the web for more information. Make sure to include the official homepage as well as the get started tutorial.

Write the first program hello.go in it:

package main

import (
  "fmt"
)

func main() {
  fmt.Println("Hello, world!")
}

then run it:

$ go run hello.go
Hello, world!

Modules, Packages, Repositories

For beginners who get started with the get started tutorial, it may be confusing about the terminology used and their meanings, like modules, packages, repositories, and the their naming schemes. They are dispersed in different documents. The How to Write Go Code guide has a brief summary:

  • package is a collection of source files in the same directory that are compiled together.
  • module is associated with a directory containing a go.mod file, which declares its module path. A module contains the packages in this directory, as well as its subdirectories, up to the next subdirectory containing another go.mod file (if any)..
  • A repository contains one or more modules, typically only one. A module can be defined locally without belonging to a repository. However, it's a good habit to organize your code as if you will publish it someday.
  • A package path is the module path joined with the subdirectory containing the package (relative to the module root). For example, the module "golang.org/x/net" contains a package in the directory "html" . That package's path is "golang.org/x/net/html"

Accordingly, in the program above:

  • jyrobin/noter is the module path we declare.
  • Running go mod init jyrobin/noter creates the go.mod file in the current folder declaring it.
  • Maybe it is better to use jyrobin.com/noter as the module path instead, even though there is no place hosting this repo if I choose to publish it.

A Dummy CLI App

To get started with a CLI app, I use urfave/cli. Copy and adapt urfave/cli's get started program and save as main.go:

package main

import (
    "fmt"
    "log"
    "os"

    "github.com/urfave/cli/v2"
)

func main() {
    app := &cli.App{
        Name:  "noter",
        Usage: "Note taking",
        Action: func(*cli.Context) error {
            fmt.Println("Taking notes...")
            return nil
        },
    }

    if err := app.Run(os.Args); err != nil {
        log.Fatal(err)
    }
}

and run the program:

$ go run main.go
Taking notes...

Since it is working, let's add some dummy commands and also remove the main action:

package main

import (
    "fmt"
    "log"
    "os"

    "github.com/urfave/cli/v2"
)

func main() {
    app := &cli.App{
        Name:  "noter",
        Usage: "A note taker",
        Commands: []*cli.Command{
            {
                Name:    "list",
                Aliases: []string{"l"},
                Usage:   "List notes",
                Action: func(ctx *cli.Context) error {
                    fmt.Println("Listing notes...")
                    return nil
                },
            },
            {
                Name:    "show",
                Aliases: []string{"s"},
                Usage:   "Show note",
                Action: func(ctx *cli.Context) error {
                    fmt.Println("Showing note", ctx.Args().First())
                    return nil
                },
            },
        },
    }

    if err := app.Run(os.Args); err != nil {
        log.Fatal(err)
    }
}

Now run the program with or without command and arguments:

$ go run main.go list
Listing notes...
$ go run main.go show 123
Showing note 123
$ go run main.go
NAME:
   noter - A note taker

USAGE:
   noter [global options] command [command options]

COMMANDS:
   list, l  list notes
   show, s  Show note
   help, h  Shows a list of commands or help for one command

GLOBAL OPTIONS:
   --help, -h  show help

Database Access

We can follow the example from the official tutorial. It is very useful to have a deeper understanding of accessing SQL databases with Golang. But here I'll try the GORM library, to compare with using SQLAlchemy when doing Python programming.

First, visit the official site, and follow the installation guide:

go get -u gorm.io/gorm
go get -u gorm.io/driver/sqlite

Adapt the corresponding sample code in main.go. Here I added an temporary "add" command which just inserts random notes:

import (
    ...
    "gorm.io/gorm"
    "gorm.io/driver/sqlite"
)

type Note struct {
    gorm.Model
    Title  string
    Text   string
}

func main() {

    db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
    if err != nil {
        panic("failed to connect database")
    }

    db.AutoMigrate(&Note{})

    app := &cli.App{
        Name:  "noter",
        Usage: "A note taker",
        Commands: []*cli.Command{
            {
                Name:    "list",
                Aliases: []string{"l"},
                Usage:   "List notes",
                Action: func(ctx *cli.Context) error {
                    var notes []Note
                    db.Find(&notes)
                    for _, note := range notes {
                        fmt.Println(note.ID, note.Title)
                    }
                    return nil
                },
            },
            {
                Name:    "show",
                Aliases: []string{"s"},
                Usage:   "Show note",
                Flags: []cli.Flag{
                    &cli.IntFlag{
                        Name:  "id",
                        Usage: "Note ID",
                    },
                },
                Action: func(ctx *cli.Context) error {
                    var note Note
                    db.First(&note, ctx.Int("id"))
                    fmt.Println(note.ID, note.Title, note.Text)
                    return nil
                },
            },
            {
                Name:    "add",
                Aliases: []string{"a"},
                Usage:   "Create note",
                Flags: []cli.Flag{
                    &cli.StringFlag{
                        Name:  "title",
                        Usage: "Title",
                    },
                    &cli.StringFlag{
                        Name:  "text",
                        Usage: "Text",
                    },
                },
                Action: func(ctx *cli.Context) error {
                    note := &Note{Title: ctx.String("title"), Text: ctx.String("text")}
                    db.Create(note)
                    fmt.Println("Created",  note.ID)
                    return nil
                },
            },
            {
                Name:    "delete",
                Aliases: []string{"d"},
                Usage:   "Delete note",
                Flags: []cli.Flag{
                    &cli.IntFlag{
                        Name:  "id",
                        Usage: "Note ID",
                    },
                },
                Action: func(ctx *cli.Context) error {
                    var note Note
                    db.Delete(&note, ctx.Int("id"))
                    return nil
                },
            },
        },
    }

    if err := app.Run(os.Args); err != nil {
        log.Fatal(err)
    }
}

Instead of running main.go directly, let's build it first and run the resulting executable:

go build -o noter main.go

Run the executable noter in current directory:

$ ./noter list
$ ./noter add --title Title1 --text Text1
Created 1
$ ./noter add --title Title2 --text Text2
Created 2
$ ./noter add --title Title3 --text Text3
Created 3
$ ./noter list
1 Title1
2 Title2
3 Title3
$ ./noter show --id 2
2 Title2 Text2
$ ./noter delete --id 1
$ ./noter list
2 Title2
3 Title3

Separate Models from Commands

We mix all aspects of the code in one place - main.go. What if we want to divide it into different packages: one for models, and one for commands, and use them from main.go. It is not difficult to do.

First, create file models.go in the subfolder models of current folder:

package models

import (
    "gorm.io/gorm"
)

type Note struct {
    gorm.Model
    Title  string
    Text   string
}

then think about what we want for main.go:

package main

import (
    "log"
    "os"

    "github.com/urfave/cli/v2"

    "gorm.io/gorm"
    "gorm.io/driver/sqlite"

    "jyrobin/noter/models"
    "jyrobin/noter/cmds"
)

func main() {

    db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
    if err != nil {
        panic("failed to connect database")
    }

    db.AutoMigrate(&models.Note{})

    app := &cli.App{
        Name:  "noter",
        Usage: "A note taker",
        Commands: cmds.AllCommands(db),
    }

    if err := app.Run(os.Args); err != nil {
        log.Fatal(err)
    }
}

main.go imports and use models and cmds packages. But to call the AllCommands function of cmds to get a list of commands, we need to pass in the db instance.

We can just copy the struct tree over, i.e. declaring the command structure in one shot. However, we generally want to define commands at the "top level" of possibly multiple programs. So in the example below, in cmds/commands.go, I build and return the list of commands via factory functions:

package cmds

import (
    "fmt"

    "github.com/urfave/cli/v2"
    "gorm.io/gorm"

    "jyrobin/noter/models"
)

func AllCommands(db *gorm.DB) []*cli.Command {
    return []*cli.Command{
        listCommand(db),
        showCommand(db),
        addCommand(db),
        deleteCommand(db),
    }
}

func listCommand(db *gorm.DB) *cli.Command {
    return &cli.Command{
        Name:    "list",
        Aliases: []string{"l"},
        Usage:   "List notes",
        Action: func(ctx *cli.Context) error {
            var notes []models.Note
            db.Find(&notes)
            for _, note := range notes {
                fmt.Println(note.ID, note.Title)
            }
            return nil
        },
    }
}

func showCommand(db *gorm.DB) *cli.Command {
    return &cli.Command{
        Name:    "show",
        Aliases: []string{"s"},
        Usage:   "Show note",
        Flags: []cli.Flag{
            &cli.IntFlag{
                Name:  "id",
                Usage: "Note ID",
            },
        },
        Action: func(ctx *cli.Context) error {
            var note models.Note
            db.First(&note, ctx.Int("id"))
            fmt.Println(note.ID, note.Title, note.Text)
            return nil
        },
    }
}

func addCommand(db *gorm.DB) *cli.Command {
    return &cli.Command{
        Name:    "add",
        Aliases: []string{"a"},
        Usage:   "Create note",
        Flags: []cli.Flag{
            &cli.StringFlag{
                Name:  "title",
                Usage: "Title",
            },
            &cli.StringFlag{
                Name:  "text",
                Usage: "Text",
            },
        },
        Action: func(ctx *cli.Context) error {
            note := &models.Note{
                Title: ctx.String("title"),
                Text: ctx.String("text"),
            }
            db.Create(note)
            fmt.Println("Created",  note.ID)
            return nil
        },
    }
}

func deleteCommand(db *gorm.DB) *cli.Command {
    return &cli.Command{
        Name:    "delete",
        Aliases: []string{"d"},
        Usage:   "Delete note",
        Flags: []cli.Flag{
            &cli.IntFlag{
                Name:  "id",
                Usage: "Note ID",
            },
        },
        Action: func(ctx *cli.Context) error {
            var note models.Note
            db.Delete(&note, ctx.Int("id"))
            return nil
        },
    }
}

Separate Domain from CLI

The project organization so far is quick common, as everything is under the umbrella of a CLI note taking app. If the application grows larger, for example to include web interface, we may:

  • create a web package to contain code related to web interface
  • create a cli package and move main.go and cmds in
  • can create a cmd folder containing two packages cli and web, containing the entry code, for example cmd/cli/main.go (the main.go above) for CLI and cmd/web/main.go for the web interface
  • create a pkg folder and move models and (future) related general utilities in (also rename models to notes to be more specific)

The intention is to factor out common abstraction into a separate unit for sharing (placed in pkg to signify such intension), and to not mixing web interface and CLI.

However, this may become a big project including too much for a project. So another way is to move models out is its own module, and make the CLI and the web interface separate modules. We can achieve this by

  • create a separate notes module, with path jyrobin/notes, containing the model related code
  • the noter module remains, containing primarily CLI-based interaction to the models
  • create a noter-web module with path jyrobin/noterweb, using whatever web framework