DEV Community

Abhishek Gupta for ITNEXT

Posted on

Yet another TODO app! With Redis and Go [Part 2]

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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)
}
...
Enter fullscreen mode Exit fullscreen mode

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")
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

check SADD for 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()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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.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)
....
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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()
    ....
Enter fullscreen mode Exit fullscreen mode

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)