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:
- A package is a collection of source files in the same directory that are compiled together.
- A 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 anothergo.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 thego.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(¬es)
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(¬e, 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(¬e, 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(¬es)
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(¬e, 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(¬e, 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 movemain.go
andcmds
in - can create a
cmd
folder containing two packagescli
andweb
, containing the entry code, for examplecmd/cli/main.go
(themain.go
above) for CLI andcmd/web/main.go
for the web interface - create a
pkg
folder and movemodels
and (future) related general utilities in (also renamemodels
tonotes
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 pathjyrobin/notes
, containing the model related code - the
noter
module remains, containing primarily CLI-based interaction to the models - create a
noter-web
module with pathjyrobin/noterweb
, using whatever web framework