Пишем простой менеджер кеша в памяти на Go

в 8:11, , рубрики: cache, Go, golang

В процессе работы над небольшими проектами часто возникает необходимость в кешировании данных и бывает так, что нет возможности использовать Redis или Memcache. В таких ситуациях подойдет простой и достаточно эффективный способ без использования дополнительных инструментов — кеширование в оперативной памяти.
В этой статье я расскажу, с чего начать, чтобы самостоятельно написать менеджер кеша в памяти на Go.

Внимание! Данная статья предназначена для начинающих разработчиков исключительно в академических целях и здесь не рассматриваются такие инструменты как Redis, Memcache и т.д
Кроме того мы не будем углубляться в проблемы выделения памяти.

Для простоты ограничимся тремя основными методами: установка Set, получение Get и удаление Delete.

Данные будем хранить в формате ключ/значение.

Структура

Первое, что необходимо сделать, это создать структуру описывающую наш контейнер-хранилище:

type Cache struct {
    sync.RWMutex
    defaultExpiration time.Duration
    cleanupInterval   time.Duration
    items             map[string]Item
}

  • sync.RWMutex — для безопасного доступа к данным во время чтения/записи (подробнее о мьютексах https://gobyexample.com/mutexes),
  • defaultExpiration — продолжительность жизни кеша по-умолчанию (этот параметр можно будет переопределить для каждого элемента)
  • cleanupInterval — интервал, через который запускается механизм очистки кеша (Garbage Collector, далее GC)
  • items — элементы кеша (в формате ключ/значение)

Теперь опишем структуру для элемента:

type Item struct {
    Value      interface{}
    Created    time.Time
    Duration   time.Duration
    Expiration int64
}

  • Value — значение. Так как оно может быть любое (число/строка/массив и т.д) необходимо указать в качестве типа interface{},
  • Created — время создания кеша,
  • Duration — продолжительность жизни,
  • Expiration — время истечения (в UnixNano) — по нему будем проверять актуальность кеша

Инициализация хранилища

Начнем с инициализации нового контейнера-хранилища:

func New(defaultExpiration, cleanupInterval time.Duration) *Cache {

    // инициализируем карту(map) в паре ключ(string)/значение(Item)
    items := make(map[string]Item)

    cache := Cache{
        items:             items,
        defaultExpiration: defaultExpiration,
        cleanupInterval:   cleanupInterval,
    }

    // Если интервал очистки больше 0, запускаем GC (удаление устаревших элементов)
    if cleanupInterval > 0 {
        cache.StartGC() // данный метод рассматривается ниже
    }

    return &cache
}

Инициализация нового экземпляра кеша принимает два аргумента: defaultExpiration и cleanupInterval

  • defaultExpiration — время жизни кеша по-умолчанию, если установлено значение меньше или равно 0 — время жизни кеша бессрочно.
  • cleanupInterval — интервал между удалением просроченного кеша. При установленном значении меньше или равно 0 — очистка и удаление просроченного кеша не происходит.

На выходе получаем контейнер со структурой Cache

Будьте внимательны при установке этих параметров, слишком маленькие или слишком большие значения могут привести к нежелательным последствиям, например если установить cleanupInterval = 1 * time.Second поиск просроченных ключей будет происходить каждую секунду, что негативно скажется на производительности вашей программы. И наоборот установив cleanupInterval = 168 * time.Hour — в памяти будет накапливаться неиспользуемые элементы.

Установка значений

После того как контейнер создан, хорошо бы иметь возможность записывать в него данные, для этого напишем реализацию метода Set

func (c *Cache) Set(key string, value interface{}, duration time.Duration) {

    var expiration int64

    // Если продолжительность жизни равна 0 - используется значение по-умолчанию
    if duration == 0 {
        duration = c.defaultExpiration
    }

    // Устанавливаем время истечения кеша
    if duration > 0 {
        expiration = time.Now().Add(duration).UnixNano()
    }

    c.Lock()

    defer c.Unlock()

    c.items[key] = Item{
        Value:      value,
        Expiration: expiration,
        Created:    time.Now(),
        Duration:   duration,
    }

}

Set добавляет новый элемент в кэш или заменяет существующий. При этом проверка на существования ключей не происходит. В качестве аргументов принимает: ключ-идентификатор в виде строки key, значение value и продолжительность жизни кеша duration.

Получение значений

С помощью Set мы записали данные в хранилище, теперь реализуем метод для их получения Get

func (c *Cache) Get(key string) (interface{}, bool) {

    c.RLock()

    defer c.RUnlock()

    item, found := c.items[key]

    // ключ не найден
    if !found {
        return nil, false
    }

    // Проверка на установку времени истечения, в противном случае он бессрочный
    if item.Expiration > 0 {

        // Если в момент запроса кеш устарел возвращаем nil
        if time.Now().UnixNano() > item.Expiration {
            return nil, false
        }

    }

    return item.Value, true
}

Get возвращает значение (или nil) и второй параметр bool равный true если ключ найден и false если ключ не найден или кеш устарел.

Удаление кеша

Теперь когда у нас есть установка и получение, необходимо иметь возможность удалить кеш (если он нам больше не нужен) для этого напишем метод Delete

func (c *Cache) Delete(key string) error {

    c.Lock()

    defer c.Unlock()

    if _, found := c.items[key]; !found {
        return errors.New("Key not found")
    }

    delete(c.items, key)

    return nil
}

Delete удаляет элемент по ключу, если ключа не существует возвращает ошибку.

Сборка мусора

У нас есть добавление, получение и удаление. Осталось реализовать поиск просроченных ключей с последующей очисткой (GC)
Для этого напишем метод StartGC, который запускается при инициализация нового экземпляра кеша New и работает пока программа не будет завершена.

func (c *Cache) StartGC()  {
    go c.GC()
}

func (c *Cache) GC() {

    for {
        // ожидаем время установленное в cleanupInterval
        <-time.After(c.cleanupInterval)

        if c.items == nil {
            return
        }

        // Ищем элементы с истекшим временем жизни и удаляем из хранилища
        if keys := c.expiredKeys(); len(keys) != 0 {
            c.clearItems(keys)

        }

    }

}

// expiredKeys возвращает список "просроченных" ключей
func (c *Cache) expiredKeys() (keys []string) {

    c.RLock()

    defer c.RUnlock()

    for k, i := range c.items {
        if time.Now().UnixNano() > i.Expiration {
            keys = append(keys, k)
        }
    }

    return
}

// clearItems удаляет ключи из переданного списка, в нашем случае "просроченные"
func (c *Cache) clearItems(keys []string) {

    c.Lock()

    defer c.Unlock()

    for _, k := range keys {
        delete(c.items, k)
    }
}

Что дальше?

Теперь у нас есть менеджер кеша с минимальным функционалом, его будет достаточно для самых простых задач. Если этого мало (а в 95% случаев так и есть) в качестве следующего шага можно самостоятельно реализовать методы:

Count — получение кол-ва элементов в кеше
GetItem — получение элемента кеша
Rename — переименования ключа
Copy — копирование элемента
Increment — инкремент
Decrement — декремент
Exist — проверка элемента на существование
Expire — проверка кеша на истечение срока жизни
FlushAll — очистка всех данных
SaveFile — сохранение данных в файл
LoadFile — загрузка данных из файла

Это далеко не полный список, но для базового функционала скорее всего хватит.

Исходники c примером на github

Если вам необходим готовый менеджер кеша в памяти рекомендую обратить внимание на следующие проекты:
Реализация go-cache от patrickmn
MemoryCache от beego

Автор: Maxchagin

Источник

Поделиться

* - обязательные к заполнению поля