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
Redisrelated 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:
CreateTodoListTodosUpdateTodoDeleteTodo
I have used the go-redis
redigois 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 Strings as atomic counters using INCR (and related commands)
id, err := c.Incr(todoIDCounter).Result()
check
INCRcommand 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
SADDfor details https://redis.io/commands/sadd
Finally, the todo info (id, description, status) is stored in a HASH. Redis Hashes 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
HMSETfor 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
SMEMBERSfor 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.Todos 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
HGETALLfor refernece https://redis.io/commands/hgetall
DeleteTodo
To delete a todo, the HASH containing the info is deleted
c.Del("todo:" + id).Result()
check
DELfor 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
SREMfor 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
HMSETfor 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)