This is the second (and final) part of a blog series which covers some of the Redis data structures with the help of a simple yet practical todo app π built with Go
cobra
(a popular library for CLI apps) and Redis
as the backend store.
Part 1 covered the overview, setup and the process of trying out the todo
app. In this part, we will peek under the hood and walk through the code
The code is available on Github
Before we dive in, here is a refresher of what you can do with the todo
CLI - good old CRUD
! From todo --help
:
Yet another TODO app. Uses Go and Redis
Usage:
todo [command]
Available Commands:
create create a todo with description
delete delete a todo
help Help about any command
list list all todos
update update todo description, status or both
Flags:
-h, --help help for todo
-v, --version version for todo
Use "todo [command] --help" for more information about a command.
Cobra
is used as the CLI framework. Its a popular project and powers CLI tools such as docker
, kubectl
etc.
This is what the code structure looks like:
.
βββ cmd
βΒ Β βββ create.go
βΒ Β βββ delete.go
βΒ Β βββ list.go
βΒ Β βββ root.go
βΒ Β βββ update.go
βββ db
βΒ Β βββ todo-redis.go
βββ go.mod
βββ go.sum
βββ main.go
Each todo
operation (create
, list
etc.) is governed by a cobra
command. The cmd
pacakge contains the implementations e.g. create.go
, list.go
etc.
The root
command
In cobra
, the root command is the parent command which is at the top-most level. Other sub-commands can be added to it. In this case, todo
is the root command and create
, list
etc. are sub-commands (also, each command can have one or more flags which can be passed during invocation)
The todo
root command is defined in cmd/root.go
var rootCmd = &cobra.Command{Use: "todo", Short: "manage your todos", Version: "0.1.0"}
Note that it does not have any specific flags - it just has sub-commands. Let's look at them
Sub-commands - create
, list
, update
, delete
The sub-command implementation logic follows the same pattern:
- use an
init()
function to bootstrap the command - set its flags, mark mandatory flag (if needed) and add it to the root command - define the execution logic/function - in our case, it's the
Redis
related operations (we will dive into them soon)
Here is what the todo create
implementation looks like (in cmd/create.go
)
...
var createCmd = &cobra.Command{Use: "create", Short: "create a todo with description", Run: Create}
func init() {
createCmd.Flags().String("description", "", "create todo with description")
createCmd.MarkFlagRequired("description")
rootCmd.AddCommand(createCmd)
}
// Create - todo create --description <text>
func Create(cmd *cobra.Command, args []string) {
desc := cmd.Flag("description").Value.String()
db.CreateTodo(desc)
}
...
todo create
needs a mandatory value for description
and uses the func Create(cmd *cobra.Command, args []string)
to perform the actual todo
creation. It invokes db.CreateTodo(desc)
for Redis related operations required to save todo
info in Redis.
Here is a snippet for todo update
implementation (in cmd/update.go
)
...
var updateCmd = &cobra.Command{Use: "update", Short: "update todo description, status or both", Run: Update}
func init() {
updateCmd.Flags().String("id", "", "id of the todo you want to update")
updateCmd.MarkFlagRequired("id")
updateCmd.Flags().String("description", "", "new description")
updateCmd.Flags().String("status", "", "new status: completed, pending, in-progress")
rootCmd.AddCommand(updateCmd)
}
// Update - todo update --id <id> --status <new status> --description <new description>
func Update(cmd *cobra.Command, args []string) {
id := cmd.Flag("id").Value.String()
desc := cmd.Flag("description").Value.String()
status := cmd.Flag("status").Value.String()
if desc == "" && status == "" {
log.Fatalf("either description or status is required")
}
if status == "completed" || status == "pending" || status == "in-progress" || status == "" {
db.UpdateTodo(id, desc, status)
} else {
log.Fatalf("provide valid status - completed, pending or in-progress")
}
}
todo update
needs a mandatory --id
flag and either a status
or description
(can be both) need to be provided. It further invokes db.UpdateTodo
for Redis related operations to update the todo
info passed in via CLI
Redis
Now, let's explore the meat of the app - Redis related logic to manage todo
info. Redis operations have been centralized in one single place i.e. the todo-redis.go
in the db
package. It has four functions, each of which maps to the respective todo
sub-commands:
CreateTodo
ListTodos
UpdateTodo
DeleteTodo
I have used the go-redis
redigo
is another popular client
All the commands start off by connecting to Redis
(obviously!)
c := redis.NewClient(&redis.Options{Addr: redisHost})
err := c.Ping().Err()
if err != nil {
log.Fatal("redis connect failed", err)
}
defer c.Close()
This is a single user cli
app, not a long running server component. So we can connect and disconnect after individual operations rather than handling Redis client redis.Client
at a global level. Now we will look at relevant snippets of each of the operations
CreateTodo
To create a todo
in Redis, we use a counter to serve as the todo id
. Redis allows you to use String
s as atomic counters using INCR
(and related commands)
id, err := c.Incr(todoIDCounter).Result()
check
INCR
command for reference https://redis.io/commands/incr
This incremented ID (prepended with todo:
) in a Redis SET
. A Redis Set
is an unordered collection of Strings
and does not allow duplicates. This id
forms the name of the HASH
(next step) in which we will store the todo
details e.g. for todo
with id 42, the details will be stored in a HASH
named todo:42
. This makes it easy to list
, update
and delete
todos
err = c.SAdd(todoIDsSet, todoid).Err()
check
SADD
for details https://redis.io/commands/sadd
Finally, the todo
info (id
, description
, status
) is stored in a HASH
. Redis Hash
es are maps between string fields and string values. This makes them suitable for storing object representations such as todo
info in this case
todo := map[string]interface{}{"desc": desc, "status": statusPending}
err = c.HMSet(todoid, todo).Err()
check
HMSET
for refernece https://redis.io/commands/hmset
ListTodos
To get todos, we just fetch all the members in the set i.e. todo:1
, todo:2
etc.
todoHashNames, err := c.SMembers(todoIDsSet).Result()
check
SMEMBERS
for reference https://redis.io/commands/smembers
We loop through these and search each HASH
, extract the info (id
, description
, status
), create a slice of db.Todo
s which is finally presented in a tabular format in the CLI app
for _, todoHashName := range todoHashNames {
id := strings.Split(todoHashName, ":")[1]
todoMap, err := c.HGetAll(todoHashName).Result()
....
todo = Todo{id, todoMap["desc"], todoMap["status"]}
....
todos = append(todos, todo)
....
check
HGETALL
for refernece https://redis.io/commands/hgetall
DeleteTodo
To delete a todo, the HASH
containing the info is deleted
c.Del("todo:" + id).Result()
check
DEL
for refernece https://redis.io/commands/del
This is followed by removal of the todo
id entry from the SET
err = c.SRem(todoIDsSet, "todo:"+id).Err()
check
SREM
for refernece https://redis.io/commands/srem
UpdateTodo
In order to update a todo
by id, we first need to confirm whether its a valid id
. For this, all we need to is check whether the SET
contains that todo
exists, err := c.SIsMember(todoIDsSet, "todo:"+id).Result()
If it does, we can proceed to update its info. We create a map
with the new status
, description
(or both) as passed in by the user and invoke HMSet
function
....
updatedTodo := map[string]interface{}{}
if status != "" {
updatedTodo["status"] = status
}
if desc != "" {
updatedTodo["desc"] = desc
}
c.HMSet("todo:"+id, updatedTodo).Err()
....
check
HMSET
for refernece https://redis.io/commands/hmset
This concludes the two-part blog series. As always, stay tuned for more! If found this useful, don't forget to like and share π Happy to get your feedback via Twitter or just drop a comment ππ»
Top comments (0)