Разработка компонента кеширования на Golang

GolangBeginner
Практиковаться сейчас

Введение

В этом проекте мы узнаем о принципах и значении кеширования, а затем спроектируем и реализуем компонент кеширования на языке Go.

Кеширование - широко используемая технология в компьютерных системах для повышения производительности путём хранения часто запрашиваемых данных в памяти. Это позволяет осуществлять быстрый доступ и уменьшает необходимость обращения к более медленным источникам данных, таким как базы данных или удаленные сервисы.

В этом проекте мы узнаем о принципах и преимуществах кеширования. Также мы спроектируем и реализуем компонент кеширования на языке программирования Go. Компонент кеширования будет обладать такими функциональностями, как хранение кэшированных данных, управление истекшими элементами данных, импорт и экспорт данных, а также операции CRUD (Create, Read, Update, Delete).

Завершив этот проект, вы приобретёте знания и навыки в принципах кеширования, структурах данных и программировании на Go. Это позволит вам создавать эффективные и высокопроизводительные программные системы, которые эффективно используют технологии кеширования.

🎯 Задачи

В этом проекте вы узнаете:

  • Как понять принципы и значение кеширования
  • Как спроектировать систему кеширования для хранения и управления данными в памяти
  • Как реализовать операции CRUD и управление истеканием для системы кеширования
  • Как добавить функциональность для импорта и экспорта данных из системы кеширования

🏆 Достижения

После завершения этого проекта вы сможете:

  • Объяснить принципы и преимущества кеширования
  • Спроектировать систему кеширования на основе sound design principles
  • Реализовать эффективные структуры данных и алгоритмы для управления кэшем
  • Разработать операции CRUD на Go для системы кеширования
  • Сериализовать и десериализовать данные для операций импорта и экспорта

Что такое кэш?

Кэши часто встречаются в компьютерной аппаратуре. Например, в процессорах есть кэш первого уровня, второго уровня и даже третьего уровня. Принцип работы кэша заключается в том, что когда процессор нуждается в чтении данных, он сначала ищет нужные данные в кэше. Если они найдены, обрабатываются непосредственно. Если нет, данные читаются из памяти. В силу более высокой скорости кэша в процессоре по сравнению с памятью, использование кэша может ускорить скорость обработки процессора. Кеширование существует не только в аппаратном обеспечении, но и в различных программных системах. Например, в веб-системах кэши существуют на серверах, клиентах или прокси-серверах. Широко используемая CDN (Content Delivery Network) также может быть看作 a огромная система кеширования. Использование кеширования в веб-системах带来 много преимуществ, таких как уменьшение сетевого трафика, снижение задержки доступа клиента и уменьшение нагрузки на сервер.

В настоящее время есть много высокопроизводительных систем кеширования, таких как Memcache, Redis и т.д. Особенно Redis, который сейчас широко используется в различных веб-сервисах. Поскольку уже существуют эти богатые в функциях системы кеширования, почему мы по-прежнему нуждаемся в реализации собственной системы кеширования? Основных причин этого два. Во-первых, реализуя это самостоятельно, мы можем понять принцип работы системы кеширования, что является классической причиной. Во-вторых, системы кеширования типа Redis существуют независимо. Если нам нужно разработать только простое приложение, использование отдельного сервера Redis может быть слишком сложным. В этом случае было бы лучше, если есть богатый в функциях пакет программ, реализующий эти функции. Просто импортируя этот пакет программ, мы можем достичь функциональности кеширования без необходимости отдельного сервера Redis.

Конструкторская часть системы кеширования

В системе кеширования кэшированные данные обычно хранятся в памяти. Поэтому система кеширования, которую мы проектируем, должна управлять данными в памяти определенным образом. Если система выключается, не потеряются ли данные? Фактически, в большинстве случаев система кеширования также поддерживает запись данных из памяти в файл. При перезапуске системы данные из файла могут быть загружены обратно в память. Таким образом, даже при отключении системы кэш-данные не будут потеряны.

Одновременно система кеширования также обеспечивает механизм очистки истекших данных. Это означает, что каждый элемент данных в кэше имеет срок жизни. Если элемент данных истек, он будет удален из памяти. В результате горячие данные всегда будут доступны, в то время как холодные данные будут удалены, так как их кэшировать не имеет смысла.

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

Основываясь на вышеописанном анализе, можно сделать вывод, что система кеширования должна обладать следующими функциональностями:

  • Хранение кэшированных данных
  • Управление истекшими элементами данных
  • Импорт и экспорт данных из памяти
  • Предоставление интерфейсов CRUD.

Подготовка к разработке

Во-первых, создайте рабочую директорию и задайте переменную окружения GOPATH:

cd ~/project/
mkdir -p golang/src
export GOPATH=~/project/golang

В вышеописанных шагах мы создаем директорию ~/project/golang и задаем ее в качестве GOPATH для последующих экспериментов.

Базовая структура системы кеширования

Кэшированные данные должны быть сохранены в памяти, чтобы обеспечить быстрый доступ. Какая структура данных должна использоваться для хранения элементов данных? В общем, для хранения элементов данных используется хэш-таблица, так как это обеспечивает лучшую производительность при доступе к данным. В языке Go мы не нужно реализовывать собственную хэш-таблицу, так как встроенный тип map уже реализует хэш-таблицу. Таким образом, мы можем напрямую сохранять элементы кэша в map.

Поскольку система кеширования также поддерживает очистку истекших данных, элементы кэша должны иметь срок жизни. Это означает, что элементы кэша должны быть заключены в отдельный контейнер и сохранены в системе кеширования. Для этого сначала нужно реализовать элемент кэша. Создайте новую директорию cache в директории GOPATH/src, а затем создайте исходный файл cache.go:

package cache

import (
    "encoding/gob"
    "fmt"
    "io"
    "os"
    "sync"
    "time"
)

type Item struct {
    Object     interface{} // Фактический элемент данных
    Expiration int64       // Срок жизни
}

// Проверить, истек ли элемент данных
func (item Item) Expired() bool {
    if item.Expiration == 0 {
        return false
    }
    return time.Now().UnixNano() > item.Expiration
}

В приведенном выше коде мы определяем структуру Item, которая имеет два поля. Object используется для хранения объектов данных любого типа, а Expiration хранит время истечения срока жизни элемента данных. Мы также предоставляем метод Expired() для типа Item, который возвращает логическое значение, указывающее, истек ли срок жизни элемента данных. Следует отметить, что время истечения срока жизни элемента данных представляет собой временную метку Unix, измеренную в наносекундах. Как определить, истек ли срок жизни элемента данных? Действительно, это довольно просто. Мы фиксируем время истечения срока жизни каждого элемента данных, а система кеширования периодически проверяет каждый элемент данных. Если время истечения срока жизни элемента данных раньше текущего времени, элемент данных удаляется из системы кеширования. Для этого мы будем использовать модуль time для реализации периодических задач.

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

const (
    // Флаг для отсутствия времени истечения срока жизни
    NoExpiration time.Duration = -1

    // Стандартное время истечения срока жизни
    DefaultExpiration time.Duration = 0
)

type Cache struct {
    defaultExpiration time.Duration
    items             map[string]Item // Сохраняем элементы кэша в map
    mu                sync.RWMutex    // Чтение-запись блокировка
    gcInterval        time.Duration   // Интервал очистки истекших данных
    stopGc            chan bool
}

// Очистить истекшие элементы кэша
func (c *Cache) gcLoop() {
    ticker := time.NewTicker(c.gcInterval)
    for {
        select {
        case <-ticker.C:
            c.DeleteExpired()
        case <-c.stopGc:
            ticker.Stop()
            return
        }
    }
}

В приведенном выше коде мы реализовали структуру Cache, которая представляет собой структуру системы кеширования. Поле items представляет собой map, используемую для хранения элементов кэша. Как можно видеть, мы также реализовали метод gcLoop(), который запускает периодическое выполнение метода DeleteExpired() с использованием time.Ticker. Ticker, созданный с использованием time.NewTicker(), будет отправлять данные из его канала ticker.C с заданным интервалом gcInterval. Мы можем использовать эту особенность для периодического выполнения метода DeleteExpired().

Для обеспечения нормального завершения функции gcLoop() мы слушаем данные из канала c.stopGc. Если в этот канал отправляются данные, мы останавливаем выполнение gcLoop(). Также отметим, что мы определили константы NoExpiration и DefaultExpiration, где前者 представляет элемент данных, срок жизни которого никогда не истекнет, а后者 представляет элемент данных с стандартным временем истечения срока жизни. Как реализовать метод DeleteExpired()? См. код ниже:

// Удалить элемент кэша
func (c *Cache) delete(k string) {
    delete(c.items, k)
}

// Удалить истекшие элементы данных
func (c *Cache) DeleteExpired() {
    now := time.Now().UnixNano()
    c.mu.Lock()
    defer c.mu.Unlock()

    for k, v := range c.items {
        if v.Expiration > 0 && now > v.Expiration {
            c.delete(k)
        }
    }
}

Как можно видеть, метод DeleteExpired() довольно прост. Мы просто проходим по всем элементам данных и удаляем истекшие.

Реализация интерфейса CRUD для системы кеширования

Теперь мы можем реализовать интерфейс CRUD для системы кеширования. Мы можем добавлять данные в систему кеширования с использованием следующих интерфейсов:

// Установить элемент кэша, перезаписать, если элемент уже существует
func (c *Cache) Set(k string, v interface{}, d time.Duration) {
    var e int64
    if d == DefaultExpiration {
        d = c.defaultExpiration
    }
    if d > 0 {
        e = time.Now().Add(d).UnixNano()
    }
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items[k] = Item{
        Object:     v,
        Expiration: e,
    }
}

// Установить элемент данных без операции блокировки
func (c *Cache) set(k string, v interface{}, d time.Duration) {
    var e int64
    if d == DefaultExpiration {
        d = c.defaultExpiration
    }
    if d > 0 {
        e = time.Now().Add(d).UnixNano()
    }
    c.items[k] = Item{
        Object:     v,
        Expiration: e,
    }
}

// Получить элемент данных, также нужно проверить, истек ли срок элемента
func (c *Cache) get(k string) (interface{}, bool) {
    item, found := c.items[k]
    if!found {
        return nil, false
    }
    if item.Expired() {
        return nil, false
    }
    return item.Object, true
}

// Добавить элемент данных, возвращает ошибку, если элемент уже существует
func (c *Cache) Add(k string, v interface{}, d time.Duration) error {
    c.mu.Lock()
    _, found := c.get(k)
    if found {
        c.mu.Unlock()
        return fmt.Errorf("Item %s already exists", k)
    }
    c.set(k, v, d)
    c.mu.Unlock()
    return nil
}

// Получить элемент данных
func (c *Cache) Get(k string) (interface{}, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()

    item, found := c.items[k]
    if!found {
        return nil, false
    }
    if item.Expired() {
        return nil, false
    }
    return item.Object, true
}

В приведенном выше коде мы реализовали интерфейсы Set() и Add(). Основное отличие между ними заключается в том, что前者 перезаписывает элемент данных в системе кеширования, если он уже существует, а后者 возвращает ошибку, если элемент данных уже существует, предотвращая неправильную перезапись кэша. Мы также реализовали метод Get(), который извлекает элемент данных из системы кеширования. Следует отметить, что истинное значение существования элемента кэша означает, что элемент существует и не истек срок его жизни.

Далее мы можем реализовать интерфейсы удаления и обновления.

// Заменить существующий элемент данных
func (c *Cache) Replace(k string, v interface{}, d time.Duration) error {
    c.mu.Lock()
    _, found := c.get(k)
    if!found {
        c.mu.Unlock()
        return fmt.Errorf("Item %s doesn't exist", k)
    }
    c.set(k, v, d)
    c.mu.Unlock()
    return nil
}

// Удалить элемент данных
func (c *Cache) Delete(k string) {
    c.mu.Lock()
    c.delete(k)
    c.mu.Unlock()
}

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

Импорт и экспорт системы кеширования

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

// Записать элементы кэша в io.Writer
func (c *Cache) Save(w io.Writer) (err error) {
    enc := gob.NewEncoder(w)
    defer func() {
        if x := recover(); x!= nil {
            err = fmt.Errorf("Error registering item types with Gob library")
        }
    }()
    c.mu.RLock()
    defer c.mu.RUnlock()
    for _, v := range c.items {
        gob.Register(v.Object)
    }
    err = enc.Encode(&c.items)
    return
}

// Сохранить элементы данных в файл
func (c *Cache) SaveToFile(file string) error {
    f, err := os.Create(file)
    if err!= nil {
        return err
    }
    if err = c.Save(f); err!= nil {
        f.Close()
        return err
    }
    return f.Close()
}

// Прочитать элементы данных из io.Reader
func (c *Cache) Load(r io.Reader) error {
    dec := gob.NewDecoder(r)
    items := map[string]Item{}
    err := dec.Decode(&items)
    if err == nil {
        c.mu.Lock()
        defer c.mu.Unlock()
        for k, v := range items {
            ov, found := c.items[k]
            if!found || ov.Expired() {
                c.items[k] = v
            }
        }
    }
    return err
}

// Загрузить элементы кэша из файла
func (c *Cache) LoadFile(file string) error {
    f, err := os.Open(file)
    if err!= nil {
        return err
    }
    if err = c.Load(f); err!= nil {
        f.Close()
        return err
    }
    return f.Close()
}

В приведенном выше коде метод Save() кодирует бинарные данные кэша с использованием модуля gob и записывает их в объект, реализующий интерфейс io.Writer. С другой стороны, метод Load() читает бинарные данные из io.Reader и затем десериализует данные с использованием модуля gob. По сути, здесь мы сериализуем и десериализуем данные кэша.

Другие интерфейсы системы кеширования

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

// Вернуть количество кэшированных элементов данных
func (c *Cache) Count() int {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return len(c.items)
}

// Очистить кэш
func (c *Cache) Flush() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items = map[string]Item{}
}

// Остановить очистку истекших элементов кэша
func (c *Cache) StopGc() {
    c.stopGc <- true
}

// Создать новую систему кеширования
func NewCache(defaultExpiration, gcInterval time.Duration) *Cache {
    c := &Cache{
        defaultExpiration: defaultExpiration,
        gcInterval:        gcInterval,
        items:             map[string]Item{},
        stopGc:            make(chan bool),
    }
    // Запустить горутину для очистки истекших элементов
    go c.gcLoop()
    return c
}

В приведенном выше коде мы добавили несколько методов. Count() вернет количество элементов данных, кэшированных в системе, Flush() очистит всю систему кеширования, а StopGc() остановит систему кеширования в очистке истекших элементов данных. Наконец, мы можем создать новую систему кеширования с использованием метода NewCache().

До сих пор вся система кеширования завершена. Она довольно простая, не так ли? Теперь проведем некоторые тесты.

Тестирование системы кеширования

Мы напишем примерную программу, исходный код которой находится в ~/project/golang/src/cache/sample/sample.go. Содержание программы следующее:

package main

import (
    "cache"
    "fmt"
    "time"
)

func main() {
    defaultExpiration, _ := time.ParseDuration("0.5h")
    gcInterval, _ := time.ParseDuration("3s")
    c := cache.NewCache(defaultExpiration, gcInterval)

    k1 := "hello labex"
    expiration, _ := time.ParseDuration("5s")

    c.Set("k1", k1, expiration)
    s, _ := time.ParseDuration("10s")
    if v, found := c.Get("k1"); found {
        fmt.Println("Found k1: ", v)
    } else {
        fmt.Println("Not found k1")
    }
    // Приостановить выполнение на 10 секунд
    time.Sleep(s)
    // Теперь k1 должно быть удалено
    if v, found := c.Get("k1"); found {
        fmt.Println("Found k1: ", v)
    } else {
        fmt.Println("Not found k1")
    }
}

Примерный код очень прост. Мы создаем систему кеширования с использованием метода NewCache, с периодом очистки истекших данных в 3 секунды и стандартным временем истечения срока жизни в полчаса. Затем мы устанавливаем элемент данных "k1" с временем истечения срока жизни в 5 секунд. После установки элемента данных мы немедленно извлекаем его, а затем приостанавливаем выполнение на 10 секунд. Когда мы снова извлекаем "k1", он должен быть удален. Программу можно выполнить с использованием следующей команды:

cd ~/project/golang/src/cache
go mod init
go run sample/sample.go

Вывод будет следующим:

Found k1: hello labex
Not found k1

Резюме

В этом проекте мы разработали систему кеширования, которая позволяет добавлять, удалять, заменять и запрашивать объекты данных. Она также включает в себя возможность удаления истекших данных. Если вы знакомы с Redis, вы, возможно, знаете, что оно позволяет увеличивать числовое значение, связанное с ключом. Например, если значение "ключ" установлено в "20", его можно увеличить до "22", передав параметр "2" в интерфейс Increment. Хотели бы вы попробовать реализовать эту функциональность с использованием нашей системы кеширования?

✨ Проверить решение и практиковаться✨ Проверить решение и практиковаться✨ Проверить решение и практиковаться✨ Проверить решение и практиковаться✨ Проверить решение и практиковаться✨ Проверить решение и практиковаться✨ Проверить решение и практиковаться✨ Проверить решение и практиковаться