golang

Использование Redis в Go

  • четверг, 21 ноября 2024 г. в 00:00:04
https://habr.com/ru/articles/860060/

Redis — хранилище из семейства нереляционных (NoSQL) баз данных. Redis является очень быстрым хранилищем данных благодаря своей архитектуре in-memory. Он идеально подходит для задач, требующих быстрого доступа к данным, таких как кэширование, очереди сообщений, сессионная информация и многое другое. Go также известен своей высокой производительностью за счет компиляции в машинный код и эффективного управления памятью.

Установка

В качестве клиента для Redis будем использовать библиотеку go-redis

go get github.com/redis/go-redis/v9

Для начала создадим новое соединение с базой данных. Первым делом создадим небольшую структуру которая будет хранить в себе информацию о конфигурации:

// storage/redis.go
type Config struct {
	Addr        string        `yaml:"addr"`
	Password    string        `yaml:"password"`
	User        string        `yaml:"user"`
	DB          int           `yaml:"db"`
	MaxRetries  int           `yaml:"max_retries"`
	DialTimeout time.Duration `yaml:"dial_timeout"`
	Timeout     time.Duration `yaml:"timeout"`
}

Где Addr - адрес нашей базы данных, Password - пароль, User - имя пользователя, DB - идентификатор базы данных, MaxRetries - максимальное количество попыток подключения, DialTimeout - таймаут для установления новых соединений, Timeout - таймаут для записи и чтения.

Теперь пропишем функцию для создания нового соединения:

// storage/redis.go
func NewClient(ctx context.Context, cfg Config) (*redis.Client, error) {
	db := redis.NewClient(&redis.Options{
		Addr:         cfg.Addr,
		Password:     cfg.Password,
		DB:           cfg.DB,
		Username:     cfg.User,
		MaxRetries:   cfg.MaxRetries,
		DialTimeout:  cfg.DialTimeout,
		ReadTimeout:  cfg.Timeout,
		WriteTimeout: cfg.Timeout,
	})

	if err := db.Ping(ctx).Err(); err != nil {
		fmt.Printf("failed to connect to redis server: %s\n", err.Error())
		return nil, err
	}

	return db, nil
}

Примеры записи и получения данных

// main.go
package main

func main() {
  cfg := storage.Config{
      Addr:        "localhost:6379",
      Password:    "test1234",
      User:        "testuser",
      DB:          0,
      MaxRetries:  5,
      DialTimeout: 10 * time.Second,
      Timeout:     5 * time.Second,
  }

  db, err := storage.NewClient(context.Background(), cfg)
  if err != nil {
      panic(err)
  }

  // Запись данных

  // db.Set(контекст, ключ, значение, время жизни в базе данных)
  if err := db.Set(context.Background(), "key", "test value", 0).Err(); err != nil {
      fmt.Printf("failed to set data, error: %s", err.Error())
  }

  if err := db.Set(context.Background(), "key2", 333, 30*time.Second).Err(); err != nil {
      fmt.Printf("failed to set data, error: %s", err.Error())
  }

  // Получение данных
  
  val, err := db.Get(context.Background(), "key").Result()
  if err == redis.Nil {
      fmt.Println("value not found")
  } else if err != nil {
      fmt.Printf("failed to get value, error: %v\n", err)
  }

  val2, err := db.Get(context.Background(), "key2").Result()
  if err == redis.Nil {
      fmt.Println("value not found")
  } else if err != nil {
      fmt.Printf("failed to get value, error: %v\n", err)
  }

  fmt.Printf("value: %v\n", val)
  fmt.Printf("value: %v\n", val2)
}

Пример кэширования данных

Как было сказано ранее Redis является очень быстрым хранилищем данных и используется для хранения кэша. В качестве примера реализуем следующий кейс:

Существует API сервер у которого существует единственная ручка - получение карточек с информацией, карточки хранятся в базе данных и их получение является дорогой по времени операцией. Для решения данной задачи предлагается сохранять полученную карточку в кэш и хранить ее там 30 секунд, при повторном запросе карточки она будет возвращаться из кэша.

Выше мы уже реализовали пример соединения с базой данных Redis поэтому перенесем его в наш проект

// main.go
package main

func main() {
  cfg := storage.Config{
      Addr:        "localhost:6379",
      Password:    "test1234",
      User:        "testuser",
      DB:          0,
      MaxRetries:  5,
      DialTimeout: 10 * time.Second,
      Timeout:     5 * time.Second,
  }

  db, err := storage.NewClient(context.Background(), cfg)
  if err != nil {
      panic(err)
  }
  
}

Теперь создадим API ручку которая будет возвращать пользователю карточку. Для начала установим библиотеку chi и  chi render :

go get github.com/go-chi/chi/v5
go get github.com/go-chi/render

Создадим структуру нашей карточки

// handlers/cache.go
type Card struct {
	ID   int    `json:"id" redis:"id"`
	Name string `json:"name" redis:"name"`
	Data string `json:"data" redis:"data"`
}

Для получения карточек создадим API ручку

// handlers/cache.go
func GetCard(w http.ResponseWriter, r *http.Request) {
  
  // Имитируем долгое обрашение в базу данных для получения карточки
  time.Sleep(3 * time.Second)

  // Получаем ID карточки из URL запроса
  idStr := chi.URLParam(r, "id")
  if idStr == "" {
      render.Status(r, http.StatusBadRequest)
      return
  }

  // Преобразуем ID из строки в целое число
  id, err := strconv.Atoi(idStr)
  if err != nil {
      render.Status(r, http.StatusBadRequest)
      return
  }

  card := Card{
      ID:   id,
      Name: "Test Card",
      Data: "This is a test card.",
  }
  
  render.Status(r, 200)
  render.JSON(w, r, card)
}

Настало время научиться сохранять структуры в хранилище Redis, если прибегнуть к официальной документации то мы увидим следующую реализацию:

type Model struct {
	Str1    string   `redis:"str1"`
	Str2    string   `redis:"str2"`
	Int     int      `redis:"int"`
	Bool    bool     `redis:"bool"`
	Ignored struct{} `redis:"-"`
}

rdb := redis.NewClient(&redis.Options{
	Addr: ":6379",
})

if _, err := rdb.Pipelined(ctx, func(rdb redis.Pipeliner) error {
	rdb.HSet(ctx, "key", "str1", "hello")
	rdb.HSet(ctx, "key", "str2", "world")
	rdb.HSet(ctx, "key", "int", 123)
	rdb.HSet(ctx, "key", "bool", 1)
	return nil
}); err != nil {
	panic(err)
}

Сразу можно обратить внимание что каждое поле структуры необходимо сохранять в отдельной строке вручную. Можно воспользоваться данным примером, но мы пойдем немного дальше и напишем свою реализацию в которой не будет необходимости прописывать каждое поле вручную, мы реализуем решение данной проблемы в качестве метода структуры, но вы можете вынести его в отдельную функцию, чтобы использовать ее для других структур.

// handlers/cache.go
func (c *Card) ToRedisSet(ctx context.Context, db *redis.Client, key string) error {
  // Получаем элементы структуры
  val := reflect.ValueOf(c).Elem()

  // Создаем функцию для записи структуры в хранилище
  settter := func(p redis.Pipeliner) error {
    // Итерируемся по полям структуры
    for i := 0; i < val.NumField(); i++ {
        field := val.Type().Field(i)
        // Получаем содержимое тэга redis
        tag := field.Tag.Get("redis")
        // Записываем значение поля и содержимое тэга redis в хранилище
        if err := p.HSet(ctx, key, tag, val.Field(i).Interface()).Err(); err != nil {
            return err
        }
    }
    // Задаем время хранения 30 секунд
    if err := p.Expire(ctx, key, 30*time.Second).Err(); err != nil {
        return err
    }
    return nil
  }

  // Сохраняем структуру в хранилище
  if _, err := db.Pipelined(ctx, settter); err != nil {
      return err
  }

  return nil
}

Важное примечание: данная реализация не подходит если в структуре есть массивы или вложенные структуры

Следующим шагом добавим сохранение карточки в нашу API ручку, после нескольких дополнений она будет выглядеть так:

// handlers/cache.go
func GetCard(ctx context.Context, db *redis.Client) http.HandlerFunc {
  return func(w http.ResponseWriter, r *http.Request) {

    time.Sleep(3 * time.Second)

    idStr := chi.URLParam(r, "id")
    if idStr == "" {
        render.Status(r, http.StatusBadRequest)
        return
    }

    id, err := strconv.Atoi(idStr)
    if err != nil {
        render.Status(r, http.StatusBadRequest)
        return
    }

    card := Card{
        ID:   id,
        Name: "Test Card",
        Data: "This is a test card.",
    }

    // Сохраняем карточку в хранилище Redis на 30 секунд
    if err := card.ToRedisSet(ctx, db, idStr); err != nil {
        render.Status(r, http.StatusInternalServerError)
        return
    }

    render.Status(r, 200)
    render.JSON(w, r, card)
  }
}

Когда у нас готовая ручка можно приступить к созданию middleware который будет проверять существует ли запрашиваемая карточка в хранилище Redis и в случае обнаружения, возвращать ее клиенту:

// handlers/cache.go
func CacheMiddleware(ctx context.Context, db *redis.Client) func(http.Handler) http.Handler {
  return func(next http.Handler) http.Handler {

    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
      // Получаем ID карточки из URL запроса
      idStr := chi.URLParam(r, "id")
      if idStr == "" {
        render.Status(r, http.StatusBadRequest)
        return
      }

      // Делаем запрос в хранилище Redis
      data := new(Card)
      if err := db.HGetAll(ctx, idStr).Scan(data); err == nil && (*data != Card{}) {
        // Если удалось найти карточку, то возвращаем ее
        render.JSON(w, r, data)
        return
      }

      // Если карточку не удалось найти, то перенаправляем запрос на нашу API ручку
      next.ServeHTTP(w, r)
    })
  }
}

Осталось совместить нашу ручку и middleware

// handlers/cache.go
func NewCardHandler(ctx context.Context, db *redis.Client) func(r chi.Router) {
  return func(r chi.Router) {
    r.With(CacheMiddleware(ctx, db)).
        Get("/{id}", GetCard(ctx, db))
  }
}

Вот мы и на финишной прямой, теперь необходимо добавить handler в main.go

// main.go
package main

import (
	"context"
	"net/http"
	"redis/handlers"
	"redis/storage"
	"time"

	"github.com/go-chi/chi/v5"
)

func main() {

	cfg := storage.Config{
		Addr:        "localhost:6379",
		Password:    "test1234",
		User:        "testuser",
		DB:          0,
		MaxRetries:  5,
		DialTimeout: 10 * time.Second,
		Timeout:     5 * time.Second,
	}

	db, err := storage.NewClient(context.Background(), cfg)
	if err != nil {
		panic(err)
	}

	router := chi.NewRouter()
	router.Route("/card", handlers.NewCardHandler(context.Background(), db))

	srv := http.Server{
		Addr:    ":8080",
		Handler: router,
	}

	if err := srv.ListenAndServe(); err != nil {
		panic(err)
	}
}

Протестируем реализацию

Попытка первого запроса
Попытка первого запроса

Время запроса составило 3 секунды, это значит что карточки не оказалось в кэше и выполнился "запрос в базу данных".

Попытка второго запроса
Попытка второго запроса

А на втором запросе время ожидания составило 4 миллисекунды, значит карточка была получена из кэша.

В результате мы смогли реализовать простейшую систему кэширования данный для API сервиса.