Développement d'un composant de mise en cache en Golang

GolangGolangBeginner
Pratiquer maintenant

💡 Ce tutoriel est traduit par l'IA à partir de la version anglaise. Pour voir la version originale, vous pouvez cliquer ici

Introduction

Dans ce projet, nous allons découvrir les principes et la signification du caching, puis nous allons concevoir et implémenter un composant de caching en utilisant le langage Go.

Le caching est une technique largement utilisée dans les systèmes informatiques pour améliorer les performances en stockant les données fréquemment consultées en mémoire. Cela permet une récupération plus rapide et réduit la nécessité d'accéder à des sources de données plus lentes, telles que des bases de données ou des services distants.

Dans ce projet, nous allons découvrir les principes et les avantages du caching. Nous allons également concevoir et implémenter un composant de caching en utilisant le langage de programmation Go. Le composant de caching aura des fonctionnalités telles que le stockage des données mises en cache, la gestion des éléments de données expirés, l'importation et l'exportation de données, et les opérations CRUD (Create, Read, Update, Delete).

En terminant ce projet, vous acquerrez des connaissances et des compétences sur les principes de caching, les structures de données et la programmation Go. Cela vous permettra de construire des systèmes logiciels efficaces et performants qui utilisent efficacement les techniques de caching.

🎯 Tâches

Dans ce projet, vous allez apprendre :

  • Comment comprendre les principes et la signification du caching
  • Comment concevoir un système de caching pour stocker et gérer des données en mémoire
  • Comment implémenter les opérations CRUD et la gestion de l'expiration pour le système de caching
  • Comment ajouter des fonctionnalités pour l'importation et l'exportation de données à partir du système de caching

🏆 Récapitulatif

Après avoir terminé ce projet, vous serez capable de :

  • Expliquer les principes et les avantages du caching
  • Concevoir un système de caching basé sur des principes de conception solides
  • Implémenter des structures de données et des algorithmes efficaces pour la gestion du cache
  • Développer des opérations CRUD en Go pour le système de caching
  • Sérialiser et désérialiser des données pour les opérations d'importation et d'exportation

Qu'est-ce qu'un cache?

Les caches sont couramment présents dans le matériel informatique. Par exemple, les processeurs ont un cache de premier niveau, un cache de deuxième niveau et même un cache de troisième niveau. Le principe de fonctionnement du cache est que lorsque le processeur a besoin de lire des données, il recherche d'abord les données requises dans le cache. Si elles sont trouvées, elles sont traitées directement. Sinon, les données sont lues à partir de la mémoire. En raison de la vitesse plus élevée du cache dans le processeur par rapport à la mémoire, l'utilisation du cache peut accélérer la vitesse de traitement du processeur. Le caching existe non seulement dans le matériel, mais également dans divers systèmes logiciels. Par exemple, dans les systèmes web, les caches existent sur les serveurs, les clients ou les serveurs proxy. Le CDN (Content Delivery Network) largement utilisé peut également être considéré comme un énorme système de caching. Il existe de nombreux avantages à utiliser le caching dans les systèmes web, tels que la réduction du trafic réseau, la diminution de la latence d'accès des clients et la réduction de la charge du serveur.

Actuellement, il existe de nombreux systèmes de caching performants, tels que Memcache, Redis, etc. En particulier, Redis est désormais largement utilisé dans divers services web. Étant donné qu'il existe déjà ces systèmes de caching riches en fonctionnalités, pourquoi avons-nous encore besoin d'implémenter notre propre système de caching? Il y a deux raisons principales pour le faire. Premièrement, en l'implémentant nous-même, nous pouvons comprendre le principe de fonctionnement du système de caching, ce qui est une raison classique. Deuxièmement, les systèmes de caching comme Redis existent indépendamment. Si nous avons seulement besoin de développer une application simple, utiliser un serveur Redis séparé peut être excessivement complexe. Dans ce cas, il serait le mieux s'il existait un package logiciel riche en fonctionnalités qui implémente ces fonctions. En import-ant simplement ce package logiciel, nous pouvons obtenir la fonctionnalité de caching sans avoir besoin d'un serveur Redis séparé.

Conception d'un système de cache

Dans un système de cache, les données mises en cache sont généralement stockées en mémoire. Par conséquent, le système de cache que nous concevons devrait gérer les données en mémoire de manière déterminée. Si le système s'arrête, les données ne seront-elles pas perdues? En fait, dans la plupart des cas, le système de cache prend également en charge l'écriture des données en mémoire dans un fichier. Lorsque le système redémarre, les données du fichier peuvent être chargées à nouveau en mémoire. De cette manière, même si le système s'arrête, les données du cache ne seront pas perdues.

En même temps, le système de cache fournit également un mécanisme pour nettoyer les données expirées. Cela signifie que chaque élément de données dans le cache a une durée de vie. Si un élément de données expire, il sera supprimé de la mémoire. En conséquence, les données les plus utilisées seront toujours disponibles tandis que les données moins utilisées seront supprimées car il n'est pas nécessaire de les mettre en cache.

Le système de cache doit également fournir des interfaces pour les opérations externes afin que les autres composants du système puissent utiliser le cache. En général, le système de cache doit prendre en charge les opérations CRUD, qui incluent la création (ajout), la lecture, la mise à jour et la suppression.

Sur la base des analyses ci-dessus, nous pouvons résumer que le système de cache doit avoir les fonctionnalités suivantes :

  • Stockage des données mises en cache
  • Gestion des éléments de données expirés
  • Importation et exportation de données à partir de la mémoire
  • Fourniture d'interfaces CRUD.

Préparatifs pour le développement

Tout d'abord, créez un répertoire de travail et définissez la variable d'environnement GOPATH :

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

Dans les étapes ci-dessus, nous créons le répertoire ~/projet/golang et le définissons comme GOPATH pour les expériences suivantes.

Structure de base d'un système de cache

Les données mises en cache doivent être stockées en mémoire pour être accessibles rapidement. Quelle structure de données devrait être utilisée pour stocker les éléments de données? En général, une table de hachage est utilisée pour stocker les éléments de données, car cela offre de meilleures performances pour accéder aux données. En Go, nous n'avons pas besoin d'implémenter notre propre table de hachage car le type intégré map implémente déjà une table de hachage. Ainsi, nous pouvons directement stocker les éléments de données du cache dans une map.

Puisque le système de cache prend également en charge le nettoyage des données expirées, les éléments de données du cache doivent avoir une durée de vie. Cela signifie que les éléments de données du cache doivent être encapsulés et enregistrés dans le système de cache. Pour ce faire, nous devons tout d'abord implémenter l'élément de données du cache. Créez un nouveau répertoire cache dans le répertoire GOPATH/src, et créez un fichier source cache.go :

package cache

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

type Item struct {
    Object     interface{} // L'élément de données réel
    Expiration int64       // Durée de vie
}

// Vérifie si l'élément de données est expiré
func (item Item) Expired() bool {
    if item.Expiration == 0 {
        return false
    }
    return time.Now().UnixNano() > item.Expiration
}

Dans le code ci-dessus, nous définissons une structure Item qui a deux champs. Object est utilisé pour stocker des objets de données de tout type, et Expiration stocke l'heure d'expiration de l'élément de données. Nous fournissons également une méthode Expired() pour le type Item, qui renvoie une valeur booléenne indiquant si l'élément de données est expiré. Il est important de noter que l'heure d'expiration d'un élément de données est un timestamp Unix mesuré en nanosecondes. Comment déterminons-nous si un élément de données est expiré? C'est en fait assez simple. Nous enregistrons l'heure d'expiration de chaque élément de données, et le système de cache vérifie périodiquement chaque élément de données. Si l'heure d'expiration d'un élément de données est antérieure à l'heure actuelle, l'élément de données est supprimé du système de cache. Pour ce faire, nous utiliserons le module time pour implémenter des tâches périodiques.

Avec cela, nous pouvons maintenant implémenter le cadre du système de cache. Le code est le suivant :

const (
    // Drapeau pour aucune heure d'expiration
    NoExpiration time.Duration = -1

    // Heure d'expiration par défaut
    DefaultExpiration time.Duration = 0
)

type Cache struct {
    defaultExpiration time.Duration
    items             map[string]Item // Stocke les éléments de données du cache dans une map
    mu                sync.RWMutex    // Verrouillage lecture-écriture
    gcInterval        time.Duration   // Intervalle de nettoyage des données expirées
    stopGc            chan bool
}

// Nettoie les éléments de données du cache expirés
func (c *Cache) gcLoop() {
    ticker := time.NewTicker(c.gcInterval)
    for {
        select {
        case <-ticker.C:
            c.DeleteExpired()
        case <-c.stopGc:
            ticker.Stop()
            return
        }
    }
}

Dans le code ci-dessus, nous avons implémenté la structure Cache, qui représente la structure du système de cache. Le champ items est une map utilisée pour stocker les éléments de données du cache. Comme vous pouvez le voir, nous avons également implémenté la méthode gcLoop(), qui programme l'exécution périodique de la méthode DeleteExpired() à l'aide d'un time.Ticker. Un ticker créé avec time.NewTicker() enverra des données à partir de son canal ticker.C à l'intervalle spécifié gcInterval. Nous pouvons utiliser cette caractéristique pour exécuter périodiquement la méthode DeleteExpired().

Pour vous assurer que la fonction gcLoop() peut se terminer normalement, nous écoutons les données du canal c.stopGc. Si des données sont envoyées à ce canal, nous arrêtons l'exécution de gcLoop(). Notez également que nous définissons les constantes NoExpiration et DefaultExpiration, où la première représente un élément de données qui n'expire jamais et la seconde représente un élément de données avec une heure d'expiration par défaut. Comment implémentons-nous DeleteExpired()? Considérez le code ci-dessous :

// Supprime un élément de données du cache
func (c *Cache) delete(k string) {
    delete(c.items, k)
}

// Supprime les éléments de données expirés
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)
        }
    }
}

Comme vous pouvez le voir, la méthode DeleteExpired() est assez simple. Nous avons juste besoin d'itérer sur tous les éléments de données et de supprimer ceux qui sont expirés.

✨ Vérifier la solution et pratiquer

Implémentation de l'interface CRUD pour le système de cache

Maintenant, nous pouvons implémenter l'interface CRUD pour le système de cache. Nous pouvons ajouter des données au système de cache en utilisant les interfaces suivantes :

// Définit un élément de données du cache, remplace l'élément s'il existe
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,
    }
}

// Définit un élément de données sans opération de verrouillage
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,
    }
}

// Récupère un élément de données, également besoin de vérifier si l'élément est expiré
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
}

// Ajoute un élément de données, renvoie une erreur si l'élément existe déjà
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
}

// Récupère un élément de données
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
}

Dans le code ci-dessus, nous avons implémenté les interfaces Set() et Add(). La principale différence entre les deux est que la première remplace l'élément de données dans le système de cache s'il existe déjà, tandis que la seconde lance une erreur si l'élément de données existe déjà, empêchant le cache d'être incorrectement remplacé. Nous avons également implémenté la méthode Get(), qui récupère l'élément de données du système de cache. Il est important de noter que la véritable signification de l'existence d'un élément de données du cache est que l'élément existe et n'est pas expiré.

Ensuite, nous pouvons implémenter les interfaces de suppression et de mise à jour.

// Remplace un élément de données existant
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
}

// Supprime un élément de données
func (c *Cache) Delete(k string) {
    c.mu.Lock()
    c.delete(k)
    c.mu.Unlock()
}

Le code ci-dessus est auto-explicatif, donc je n'entrerai pas dans les détails.

✨ Vérifier la solution et pratiquer

Importation et exportation du système de cache

Précédemment, nous avons mentionné que le système de cache prend en charge l'importation de données dans un fichier et le chargement de données à partir d'un fichier. Maintenant, implémentons cette fonctionnalité.

// Écrit les éléments de données du cache dans un 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("Erreur lors de l'enregistrement des types d'éléments avec la bibliothèque Gob")
        }
    }()
    c.mu.RLock()
    defer c.mu.RUnlock()
    for _, v := range c.items {
        gob.Register(v.Object)
    }
    err = enc.Encode(&c.items)
    return
}

// Enregistre les éléments de données dans un fichier
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()
}

// Lit les éléments de données à partir d'un 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
}

// Charge les éléments de données du cache à partir d'un fichier
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()
}

Dans le code ci-dessus, la méthode Save() encode les données binaires du cache à l'aide du module gob et les écrit dans un objet qui implémente l'interface io.Writer. D'un autre côté, la méthode Load() lit les données binaires à partir d'un io.Reader puis désérialise les données à l'aide du module gob. Essentiellement, nous sommes ici en train de sérialiser et de désérialiser les données du cache.

✨ Vérifier la solution et pratiquer

Autres interfaces du système de cache

Jusqu'à présent, la fonctionnalité de l'ensemble du système de cache a été achevée, et la majeure partie du travail a été effectuée. Terminons les tâches finales.

// Retourne le nombre d'éléments de données mis en cache
func (c *Cache) Count() int {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return len(c.items)
}

// Vide le cache
func (c *Cache) Flush() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items = map[string]Item{}
}

// Arrête le nettoyage des données expirées du cache
func (c *Cache) StopGc() {
    c.stopGc <- true
}

// Crée un nouveau système de cache
func NewCache(defaultExpiration, gcInterval time.Duration) *Cache {
    c := &Cache{
        defaultExpiration: defaultExpiration,
        gcInterval:        gcInterval,
        items:             map[string]Item{},
        stopGc:            make(chan bool),
    }
    // Démarre la goroutine de nettoyage des expirations
    go c.gcLoop()
    return c
}

Dans le code ci-dessus, nous avons ajouté plusieurs méthodes. Count() retournera le nombre d'éléments de données mis en cache dans le système, Flush() videra l'ensemble du système de cache, et StopGc() arrêtera le système de cache de nettoyer les éléments de données expirés. Enfin, nous pouvons créer un nouveau système de cache à l'aide de la méthode NewCache().

Jusqu'à présent, l'ensemble du système de cache a été achevé. C'est assez simple, n'est-ce pas? Maintenant, effectuons quelques tests.

✨ Vérifier la solution et pratiquer

Test du système de cache

Nous allons écrire un programme d'exemple dont le code source se trouve dans ~/project/golang/src/cache/sample/sample.go. Le contenu du programme est le suivant :

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")
    }
    // Pause pendant 10 secondes
    time.Sleep(s)
    // Maintenant, k1 devrait avoir été supprimé
    if v, found := c.Get("k1"); found {
        fmt.Println("Found k1: ", v)
    } else {
        fmt.Println("Not found k1")
    }
}

Le code d'exemple est très simple. Nous créons un système de cache à l'aide de la méthode NewCache, avec une période de nettoyage des expirations de 3 secondes et une durée d'expiration par défaut de demi-heure. Nous définissons ensuite un élément de données "k1" avec une durée d'expiration de 5 secondes. Après avoir défini l'élément de données, nous le récupérons immédiatement puis nous mettons en pause pendant 10 secondes. Lorsque nous récupérons "k1" à nouveau, il devrait avoir été supprimé. Le programme peut être exécuté en utilisant la commande suivante :

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

La sortie sera la suivante :

Found k1: hello labex
Not found k1
✨ Vérifier la solution et pratiquer

Sommaire

Dans ce projet, nous avons développé un système de cache qui permet d'ajouter, de supprimer, de remplacer et d'interroger des objets de données. Il inclut également la capacité de supprimer les données expirées. Si vous êtes familier avec Redis, vous savez peut-être qu'il est capable d'incrémenter une valeur numérique associée à une clé. Par exemple, si la valeur de "clé" est définie sur "20", elle peut être augmentée à "22" en passant le paramètre "2" à l'interface Increment. Voudriez-vous essayer d'implémenter cette fonctionnalité à l'aide de notre système de cache?