Introducción
En este proyecto, aprenderemos sobre los principios y la importancia del caché, y luego diseñaremos e implementaremos un componente de caché utilizando el lenguaje de programación Go.
El caché es una técnica ampliamente utilizada en los sistemas informáticos para mejorar el rendimiento al almacenar datos accedidos con frecuencia en la memoria. Esto permite una recuperación más rápida y reduce la necesidad de acceder a fuentes de datos más lentas, como bases de datos o servicios remotos.
En este proyecto, aprenderemos sobre los principios y beneficios del caché. También diseñaremos e implementaremos un componente de caché utilizando el lenguaje de programación Go. El componente de caché tendrá funcionalidades como el almacenamiento de datos en caché, la gestión de elementos de datos caducados, la importación y exportación de datos y operaciones CRUD (Crear, Leer, Actualizar, Eliminar).
Al completar este proyecto, adquirirás conocimientos y habilidades en los principios del caché, estructuras de datos y programación en Go. Esto te permitirá construir sistemas de software eficientes y de alto rendimiento que utilicen efectivamente las técnicas de caché.
🎯 Tareas
En este proyecto, aprenderás:
- Cómo entender los principios y la importancia del caché
- Cómo diseñar un sistema de caché para almacenar y gestionar datos en la memoria
- Cómo implementar operaciones CRUD y gestión de caducidad para el sistema de caché
- Cómo agregar funcionalidad para la importación y exportación de datos desde el sistema de caché
🏆 Logros
Después de completar este proyecto, serás capaz de:
- Explicar los principios y beneficios del caché
- Diseñar un sistema de caché basado en principios de diseño sólidos
- Implementar estructuras de datos y algoritmos eficientes para la gestión del caché
- Desarrollar operaciones CRUD en Go para el sistema de caché
- Serializar y deserializar datos para operaciones de importación y exportación
¿Qué es una caché?
Los cachés se encuentran comúnmente en el hardware de los computadores. Por ejemplo, las CPU tienen caché de primer nivel, caché de segundo nivel e incluso caché de tercer nivel. El principio de funcionamiento del caché es que cuando la CPU necesita leer datos, primero busca los datos requeridos en el caché. Si se encuentran, se procesan directamente. Si no, los datos se leen de la memoria. Debido a que la velocidad del caché en la CPU es mayor que la de la memoria, el uso del caché puede acelerar la velocidad de procesamiento de la CPU. El caché no solo existe en el hardware sino también en diversos sistemas de software. Por ejemplo, en los sistemas web, los cachés existen en servidores, clientes o servidores proxy. La ampliamente utilizada CDN (Content Delivery Network) también se puede considerar como un enorme sistema de caché. Hay muchos beneficios en utilizar el caché en los sistemas web, como reducir el tráfico de red, disminuir la latencia de acceso de los clientes y reducir la carga del servidor.
Actualmente, hay muchos sistemas de caché de alto rendimiento disponibles, como Memcache, Redis, etc. Especialmente Redis, que ahora se utiliza ampliamente en diversos servicios web. Dado que ya hay estos sistemas de caché con muchas características, ¿por qué todavía necesitamos implementar nuestro propio sistema de caché? Hay dos razones principales para hacer esto. Primera, al implementarlo nosotros mismos, podemos entender el principio de funcionamiento del sistema de caché, lo cual es una razón clásica. Segunda, los sistemas de caché como Redis existen de forma independiente. Si solo necesitamos desarrollar una aplicación simple, utilizar un servidor Redis separado podría ser demasiado complejo. En este caso, sería mejor si hubiera un paquete de software con muchas características que implemente estas funciones. Simplemente importando este paquete de software, podemos lograr la funcionalidad de caché sin necesidad de un servidor Redis separado.
Diseño del sistema de caché
En un sistema de caché, los datos en caché generalmente se almacenan en la memoria. Por lo tanto, el sistema de caché que diseñemos debe manejar los datos en la memoria de una manera determinada. ¿Qué pasa si el sistema se apaga? ¿No se perderán los datos? De hecho, en la mayoría de los casos, el sistema de caché también admite escribir los datos en la memoria a un archivo. Cuando el sistema se reinicia, los datos del archivo se pueden cargar nuevamente en la memoria. De esta manera, incluso si el sistema se apaga, los datos del caché no se perderán.
Al mismo tiempo, el sistema de caché también proporciona un mecanismo para eliminar los datos caducados. Esto significa que cada elemento de datos en el caché tiene una duración de vida. Si un elemento de datos expira, se eliminará de la memoria. Como resultado, los datos populares siempre estarán disponibles mientras que los datos poco utilizados se eliminarán ya que no es necesario almacenarlos en caché.
El sistema de caché también debe proporcionar interfaces para las operaciones externas para que otros componentes del sistema puedan utilizar el caché. Típicamente, el sistema de caché debe admitir operaciones CRUD, que incluyen creación (agregar), lectura, actualización y eliminación.
Basado en el análisis anterior, podemos resumir que el sistema de caché debe tener las siguientes funcionalidades:
- Almacenamiento de datos en caché
- Gestión de elementos de datos caducados
- Importación y exportación de datos desde la memoria
- Provisión de interfaces CRUD.
Preparación para el desarrollo
Primero, crea un directorio de trabajo y configura la variable de entorno GOPATH:
cd ~/proyecto/
mkdir -p golang/src
export GOPATH=~/proyecto/golang
En los pasos anteriores, creamos el directorio ~/proyecto/golang y lo configuramos como el GOPATH para los experimentos subsiguientes.
Estructura básica de un sistema de caché
Los datos en caché deben ser almacenados en la memoria para poder ser accedidos rápidamente. ¿Qué estructura de datos se debe utilizar para almacenar los elementos de datos? En general, se utiliza una tabla hash para almacenar los elementos de datos, ya que esto proporciona un mejor rendimiento para acceder a los datos. En el lenguaje de programación Go, no es necesario implementar nuestra propia tabla hash porque el tipo integrado map ya implementa una tabla hash. Entonces, podemos almacenar directamente los elementos de datos del caché en un map.
Dado que el sistema de caché también admite eliminar los datos caducados, los elementos de datos del caché deben tener una duración de vida. Esto significa que los elementos de datos del caché deben ser encapsulados y guardados en el sistema de caché. Para hacer esto, primero debemos implementar el elemento de datos del caché. Crea un nuevo directorio cache en el directorio GOPATH/src, y crea un archivo fuente cache.go:
package cache
import (
"encoding/gob"
"fmt"
"io"
"os"
"sync"
"time"
)
type Item struct {
Object interface{} // El elemento de datos real
Expiration int64 // Duración de vida
}
// Verifica si el elemento de datos ha expirado
func (item Item) Expired() bool {
if item.Expiration == 0 {
return false
}
return time.Now().UnixNano() > item.Expiration
}
En el código anterior, definimos una estructura Item que tiene dos campos. Object se utiliza para almacenar objetos de datos de cualquier tipo, y Expiration almacena la fecha de expiración del elemento de datos. También proporcionamos un método Expired() para el tipo Item, que devuelve un valor booleano que indica si el elemento de datos ha expirado. Es importante destacar que la fecha de expiración de un elemento de datos es un timestamp Unix medido en nanosegundos. ¿Cómo determinamos si un elemento de datos ha expirado? En realidad es bastante simple. Registramos la fecha de expiración de cada elemento de datos, y el sistema de caché revisa periódicamente cada elemento de datos. Si la fecha de expiración de un elemento de datos es anterior a la hora actual, el elemento de datos se elimina del sistema de caché. Para hacer esto, usaremos el módulo time para implementar tareas periódicas.
Con esto, ahora podemos implementar el marco del sistema de caché. El código es el siguiente:
const (
// Banderas para no tener fecha de expiración
NoExpiration time.Duration = -1
// Fecha de expiración predeterminada
DefaultExpiration time.Duration = 0
)
type Cache struct {
defaultExpiration time.Duration
items map[string]Item // Almacena los elementos de datos del caché en un map
mu sync.RWMutex // Bloqueo de lectura-escritura
gcInterval time.Duration // Intervalo de limpieza de datos caducados
stopGc chan bool
}
// Limpia los elementos de datos del caché caducados
func (c *Cache) gcLoop() {
ticker := time.NewTicker(c.gcInterval)
for {
select {
case <-ticker.C:
c.DeleteExpired()
case <-c.stopGc:
ticker.Stop()
return
}
}
}
En el código anterior, hemos implementado la estructura Cache, que representa la estructura del sistema de caché. El campo items es un map utilizado para almacenar los elementos de datos del caché. Como se puede ver, también hemos implementado el método gcLoop(), que programa la ejecución periódica del método DeleteExpired() utilizando un time.Ticker. Un ticker creado con time.NewTicker() enviará datos desde su canal ticker.C en el intervalo gcInterval especificado. Podemos utilizar esta característica para ejecutar periódicamente el método DeleteExpired().
Para garantizar que la función gcLoop() pueda terminar normalmente, escuchamos los datos del canal c.stopGc. Si hay datos enviados a este canal, detenemos la ejecución de gcLoop(). También observe que definimos las constantes NoExpiration y DefaultExpiration, donde la primera representa un elemento de datos que nunca expira y la segunda representa un elemento de datos con una fecha de expiración predeterminada. ¿Cómo implementamos DeleteExpired()? Consulte el código siguiente:
// Elimina un elemento de datos del caché
func (c *Cache) delete(k string) {
delete(c.items, k)
}
// Elimina los elementos de datos caducados
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)
}
}
}
Como se puede ver, el método DeleteExpired() es bastante simple. Solo debemos iterar a través de todos los elementos de datos y eliminar los caducados.
Implementación de la interfaz CRUD para el sistema de caché
Ahora, podemos implementar la interfaz CRUD para el sistema de caché. Podemos agregar datos al sistema de caché utilizando las siguientes interfaces:
// Establece un elemento de datos en el caché, sobrescribe si el elemento 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,
}
}
// Establece un elemento de datos sin operación de bloqueo
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,
}
}
// Obtiene un elemento de datos, también debe comprobar si el elemento ha expirado
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
}
// Agrega un elemento de datos, devuelve un error si el elemento ya existe
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
}
// Obtiene un elemento de datos
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
}
En el código anterior, hemos implementado las interfaces Set() y Add(). La principal diferencia entre las dos es que la primera sobrescribe el elemento de datos en el sistema de caché si ya existe, mientras que la segunda lanza un error si el elemento de datos ya existe, evitando que el caché se sobrescriba incorrectamente. También hemos implementado el método Get(), que recupera el elemento de datos del sistema de caché. Es importante destacar que el verdadero significado de la existencia de un elemento de datos del caché es que el elemento existe y no ha expirado.
A continuación, podemos implementar las interfaces de eliminación y actualización.
// Reemplaza un elemento de datos existente
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
}
// Elimina un elemento de datos
func (c *Cache) Delete(k string) {
c.mu.Lock()
c.delete(k)
c.mu.Unlock()
}
El código anterior es autoexplicativo, así que no entraré en muchos detalles.
Importación y exportación del sistema de caché
Anteriormente, mencionamos que el sistema de caché admite importar datos a un archivo y cargar datos desde un archivo. Ahora implementemos esta funcionalidad.
// Escribe los elementos de datos del caché a 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("Error registrando tipos de elementos con la biblioteca Gob")
}
}()
c.mu.RLock()
defer c.mu.RUnlock()
for _, v := range c.items {
gob.Register(v.Object)
}
err = enc.Encode(&c.items)
return
}
// Guarda los elementos de datos en un archivo
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()
}
// Lee los elementos de datos desde 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
}
// Carga los elementos de datos del caché desde un archivo
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()
}
En el código anterior, el método Save() codifica los datos binarios del caché utilizando el módulo gob y los escribe en un objeto que implementa la interfaz io.Writer. Por otro lado, el método Load() lee datos binarios desde un io.Reader y luego deserializa los datos utilizando el módulo gob. Esencialmente, aquí estamos serializando y deserializando los datos del caché.
Otras interfaces del sistema de caché
Hasta ahora, se ha completado la funcionalidad del sistema de caché en su totalidad y la mayor parte del trabajo ya está hecho. Terminemos con las tareas finales.
// Devuelve el número de elementos de datos en caché
func (c *Cache) Count() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.items)
}
// Limpia el caché
func (c *Cache) Flush() {
c.mu.Lock()
defer c.mu.Unlock()
c.items = map[string]Item{}
}
// Detiene la eliminación de datos caducados del caché
func (c *Cache) StopGc() {
c.stopGc <- true
}
// Crea un nuevo sistema de caché
func NewCache(defaultExpiration, gcInterval time.Duration) *Cache {
c := &Cache{
defaultExpiration: defaultExpiration,
gcInterval: gcInterval,
items: map[string]Item{},
stopGc: make(chan bool),
}
// Inicia la rutina goroutine de limpieza de expiración
go c.gcLoop()
return c
}
En el código anterior, hemos agregado varios métodos. Count() devolverá el número de elementos de datos almacenados en caché en el sistema, Flush() limpiará el sistema de caché completo y StopGc() detendrá el sistema de caché de eliminar elementos de datos caducados. Finalmente, podemos crear un nuevo sistema de caché utilizando el método NewCache().
Hasta ahora, se ha completado el sistema de caché en su totalidad. Es bastante simple, ¿no? Ahora hagamos algunas pruebas.
Probando el sistema de caché
Escribiremos un programa de ejemplo cuyo código fuente se encuentra en ~/project/golang/src/cache/sample/sample.go. El contenido del programa es el siguiente:
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("Encontrado k1: ", v)
} else {
fmt.Println("No encontrado k1")
}
// Pausa durante 10 segundos
time.Sleep(s)
// Ahora k1 debería haber sido eliminado
if v, found := c.Get("k1"); found {
fmt.Println("Encontrado k1: ", v)
} else {
fmt.Println("No encontrado k1")
}
}
El código de ejemplo es muy simple. Creamos un sistema de caché utilizando el método NewCache, con un período de limpieza de expiración de datos de 3 segundos y una fecha de expiración predeterminada de media hora. Luego establecemos un elemento de datos "k1" con una fecha de expiración de 5 segundos. Después de establecer el elemento de datos, lo recuperamos inmediatamente y luego pausamos durante 10 segundos. Cuando recuperamos "k1" nuevamente, debería haber sido eliminado. El programa se puede ejecutar utilizando el siguiente comando:
cd ~/project/golang/src/cache
go mod init
go run sample/sample.go
La salida será la siguiente:
Encontrado k1: hello labex
No encontrado k1
Resumen
En este proyecto, hemos desarrollado un sistema de caché que permite agregar, eliminar, reemplazar y consultar objetos de datos. También incluye la capacidad de eliminar datos caducados. Si estás familiarizado con Redis, es posible que seas consciente de su capacidad para incrementar un valor numérico asociado con una clave. Por ejemplo, si el valor de "clave" se establece en "20", se puede aumentar a "22" pasando el parámetro "2" a la interfaz Increment. ¿Te gustaría intentar implementar esta funcionalidad utilizando nuestro sistema de caché?



