サーバーサイドGo入門 #5 ~redisを使う~

前回はこちら prelude.hatenablog.jp

今回作るもの

redisを用いてReadのレスポンスを早めましょう。

ユーザーIDの追加

redisの導入にあたりまずユーザーIDをTODOに追加します。このユーザーIDをredisのキーとして用いTODOを保存します。

type TODO struct {
    gorm.Model
    // これ
    UserID      int       `json:"userID"`
    Author      string    `json:"author"`
    Name        string    `json:"name"`
    Description string    `json:"description"`
    DueDate     time.Time `json:"dueDate"`
}

ユーザーIDの追加に伴い、TODO一覧APIをユーザーIDを指定して取得するものに変更しましょう。

func list(c *gin.Context) {
    userID := c.Param("user_id")

    todos := make([]TODO, 0)
    if err := gormDB.WithContext(c).Find(&todos, "user_id = ?", userID).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, todos)
}

func setupRouter() *gin.Engine {
    router := gin.Default()
    router.GET("/todos/:user_id", list)
    router.POST("/todo", add)
    return router
}

redisの導入

redis clientの初期化

では早速redisを導入しましょう。使用するライブラリは下記です。import時にv8をつける必要があることに注意しましょう。 github.com

redisのクライアントもgorm同様にグローバルに定義し、main関数で初期化します。

// これと
var redisClient *redis.Client

func main() {
    _gormDB, err := gorm.Open(mysql.Open("root:mysql@tcp(127.0.0.1:3306)/todo?parseTime=true"), &gorm.Config{})
    if err != nil {
        panic(err)
    }
    sqlDB, err := _gormDB.DB()
    if err != nil {
        panic(err)
    }

    defer sqlDB.Close()

    if !_gormDB.Migrator().HasTable(&TODO{}) {
        if err := _gormDB.Migrator().CreateTable(&TODO{}); err != nil {
            panic(err)
        }
    }

    gormDB = _gormDB

    // これ
    redisClient = redis.NewClient(&redis.Options{
        Addr:     "localhost:6379",
        Password: "",
        DB:       0,
    })

    log.Println(setupRouter().Run(":8080"))
}

このクライアントを用いるように修正します。

redisを使う

今回はredisを下記のように使います。

  • list:ユーザーIDをキーとしたデータがredisにあればそれを使用。なければDBから取得し、redisに保存。
  • add:ユーザーIDをキーとしてredisに保存されているTODOを削除。

ちなみに、redisにsetする際に下記のinterfaceを要求されます。

type BinaryMarshaler interface {
    MarshalBinary() (data []byte, err error)
}

encoding - The Go Programming Language

そのため、set前にjsonにencodingしている点に注意してください。実際のコードはこのようになります。

func list(c *gin.Context) {
    userID := c.Param("user_id")

    todos := make([]TODO, 0)
    if b, err := redisClient.Get(c, userID).Bytes(); err == nil {
        if err := json.Unmarshal(b, &todos); err == nil {
            c.JSON(http.StatusOK, todos)
            return
        }
    }

    if err := gormDB.WithContext(c).Find(&todos, "user_id = ?", userID).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    j, err := json.Marshal(todos)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    if err := redisClient.Set(c, userID, j, 5*time.Minute).Err(); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, todos)
}

func add(c *gin.Context) {
    if c.Request.Body == http.NoBody {
        c.JSON(http.StatusBadRequest, gin.H{"error": "body is required"})
        return
    }

    var todo TODO
    if err := json.NewDecoder(c.Request.Body).Decode(&todo); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "body is invalid"})
        return
    }

    if err := gormDB.WithContext(c).Create(&todo).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    if err := redisClient.Del(c, strconv.Itoa(todo.UserID)).Err(); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, todo)
}

redisを動かす

redisのイメージを取得し、動かします。

$ docker pull redis
$ docker run --rm --name redis -p 6379:6379 redis redis-server --appendonly yes

curlで確認

下記の通りcurlを行い確認します。2度目のTODO一覧の取得時間が非常に短いこと、TODOを追加した後のTODO一覧の取得が1度目の取得と同じ程度に遅くなることを確認してください。

$ curl -X POST http://localhost:8080/todo -d \
'{"userID": 1,
  "author": "Rob",
  "name": "Develop Generics",
  "description": "Add feature of generic type system", 
  "dueDate": "2022-12-31T00:00:00Z"
}' | jq
$ curl http://locahost:8080/todos/1 | jq

コード全文

// main.go

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "strconv"
    "time"

    "github.com/gin-gonic/gin"
    "github.com/go-redis/redis/v8"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

var gormDB *gorm.DB
var redisClient *redis.Client

type TODO struct {
    gorm.Model
    UserID      int       `json:"userID"`
    Author      string    `json:"author"`
    Name        string    `json:"name"`
    Description string    `json:"description"`
    DueDate     time.Time `json:"dueDate"`
}

func list(c *gin.Context) {
    userID := c.Param("user_id")

    todos := make([]TODO, 0)
    if b, err := redisClient.Get(c, userID).Bytes(); err == nil {
        if err := json.Unmarshal(b, &todos); err == nil {
            c.JSON(http.StatusOK, todos)
            return
        }
    }

    if err := gormDB.WithContext(c).Find(&todos, "user_id = ?", userID).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    j, err := json.Marshal(todos)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    if err := redisClient.Set(c, userID, j, 5*time.Minute).Err(); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, todos)
}

func add(c *gin.Context) {
    if c.Request.Body == http.NoBody {
        c.JSON(http.StatusBadRequest, gin.H{"error": "body is required"})
        return
    }

    var todo TODO
    if err := json.NewDecoder(c.Request.Body).Decode(&todo); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "body is invalid"})
        return
    }

    if err := gormDB.WithContext(c).Create(&todo).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    if err := redisClient.Del(c, strconv.Itoa(todo.UserID)).Err(); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, todo)
}

func setupRouter() *gin.Engine {
    router := gin.Default()
    router.GET("/todos/:user_id", list)
    router.POST("/todo", add)
    return router
}

func main() {
    _gormDB, err := gorm.Open(mysql.Open("root:mysql@tcp(127.0.0.1:3306)/todo?parseTime=true"), &gorm.Config{})
    if err != nil {
        panic(err)
    }
    sqlDB, err := _gormDB.DB()
    if err != nil {
        panic(err)
    }

    defer sqlDB.Close()

    if !_gormDB.Migrator().HasTable(&TODO{}) {
        if err := _gormDB.Migrator().CreateTable(&TODO{}); err != nil {
            panic(err)
        }
    }

    gormDB = _gormDB

    redisClient = redis.NewClient(&redis.Options{
        Addr:     "localhost:6379",
        Password: "",
        DB:       0,
    })

    log.Println(setupRouter().Run(":8080"))
}

まとめ

現実のサーバーサイドの開発ではキャッシュは非常によく行われるものと思います。今回はredisを深く理解して使うことよりも、まずGoで扱ってみて最初の1歩のハードルを低くすることを目的としました。

redisを使いキャッシュしていますが今回のコードでは速くなりません。そもそも処理が少ないため、redisとのやり取りによるオーバーヘッドの方が高くつきます。

雑談

redisの後はgoroutineとgPRCの導入を書こうと思っていましたが失踪しそうです。