Introdução
Neste projeto, aprenderemos sobre os princípios e a importância do caching (armazenamento em cache), e então projetaremos e implementaremos um componente de caching usando a linguagem Go.
Caching é uma técnica amplamente utilizada em sistemas de computadores para melhorar o desempenho, armazenando dados acessados com frequência na memória. Isso permite uma recuperação mais rápida e reduz a necessidade de acessar fontes de dados mais lentas, como bancos de dados ou serviços remotos.
Neste projeto, aprenderemos sobre os princípios e benefícios do caching. Também projetaremos e implementaremos um componente de caching usando a linguagem de programação Go. O componente de caching terá funcionalidades como armazenamento de dados em cache, gerenciamento de itens de dados expirados, importação e exportação de dados e operações CRUD (Create, Read, Update, Delete).
Ao concluir este projeto, você obterá conhecimento e habilidades em princípios de caching, estruturas de dados e programação Go. Isso permitirá que você construa sistemas de software eficientes e de alto desempenho que fazem uso eficaz de técnicas de caching.
🎯 Tarefas
Neste projeto, você aprenderá:
- Como entender os princípios e a importância do caching
- Como projetar um sistema de caching para armazenar e gerenciar dados na memória
- Como implementar operações CRUD e gerenciamento de expiração para o sistema de caching
- Como adicionar funcionalidade para importar e exportar dados do sistema de caching
🏆 Conquistas
Após concluir este projeto, você será capaz de:
- Explicar os princípios e benefícios do caching
- Projetar um sistema de caching baseado em princípios de design sólidos
- Implementar estruturas de dados e algoritmos eficientes para gerenciamento de cache
- Desenvolver operações CRUD em Go para o sistema de caching
- Serializar e desserializar dados para operações de importação e exportação
O Que É um Cache?
Caches são comumente encontrados em hardware de computadores. Por exemplo, CPUs possuem cache de primeiro nível, cache de segundo nível e até cache de terceiro nível. O princípio da operação do cache é que, quando a CPU precisa ler dados, ela primeiro procura os dados necessários no cache. Se forem encontrados, são processados diretamente. Caso contrário, os dados são lidos da memória. Devido à velocidade mais rápida do cache na CPU em comparação com a memória, o uso do cache pode acelerar a velocidade de processamento da CPU. Caching não existe apenas em hardware, mas também em vários sistemas de software. Por exemplo, em sistemas web, caches existem em servidores, clientes ou servidores proxy. A CDN (Content Delivery Network) amplamente utilizada também pode ser vista como um enorme sistema de caching. Existem muitos benefícios em usar caching em sistemas web, como reduzir o tráfego de rede, diminuir a latência de acesso do cliente e reduzir a carga do servidor.
Atualmente, existem muitos sistemas de caching de alto desempenho disponíveis, como Memcache, Redis, etc. Especialmente o Redis, que agora é amplamente utilizado em vários serviços web. Já que existem esses sistemas de caching ricos em recursos, por que ainda precisamos implementar nosso próprio sistema de caching? Existem duas razões principais para fazer isso. Primeiro, ao implementá-lo nós mesmos, podemos entender o princípio de funcionamento do sistema de caching, que é uma razão clássica. Segundo, sistemas de caching como o Redis existem independentemente. Se precisarmos apenas desenvolver uma aplicação simples, usar um servidor Redis separado pode ser excessivamente complexo. Nesse caso, seria melhor se houvesse um pacote de software rico em recursos que implementasse essas funções. Simplesmente importando este pacote de software, podemos alcançar a funcionalidade de caching sem a necessidade de um servidor Redis separado.
Design do Sistema de Cache
Em um sistema de cache, os dados em cache são geralmente armazenados na memória. Portanto, o sistema de cache que projetamos deve gerenciar os dados na memória de uma certa maneira. Se o sistema for desligado, os dados não serão perdidos? Na verdade, na maioria dos casos, o sistema de cache também suporta a escrita dos dados na memória em um arquivo. Quando o sistema reinicia, os dados no arquivo podem ser carregados de volta na memória. Dessa forma, mesmo que o sistema seja desligado, os dados do cache não serão perdidos.
Ao mesmo tempo, o sistema de cache também fornece um mecanismo para limpar dados expirados. Isso significa que cada item de dados no cache tem um tempo de vida. Se um item de dados expirar, ele será excluído da memória. Como resultado, dados quentes estarão sempre disponíveis, enquanto dados frios serão excluídos, pois não é necessário que sejam armazenados em cache.
O sistema de cache também precisa fornecer interfaces para operações externas, para que outros componentes do sistema possam fazer uso do cache. Tipicamente, o sistema de cache precisa suportar operações CRUD, que incluem criação (adição), leitura, atualização e exclusão.
Com base na análise acima, podemos resumir que o sistema de cache precisa ter as seguintes funcionalidades:
- Armazenamento de dados em cache
- Gerenciamento de itens de dados expirados
- Importação e exportação de dados da memória
- Fornecimento de interfaces CRUD.
Preparação para o Desenvolvimento
Primeiramente, crie um diretório de trabalho e defina a variável de ambiente GOPATH:
cd ~/project/
mkdir -p golang/src
export GOPATH=~/project/golang
Nos passos acima, criamos o diretório ~/project/golang e o definimos como o GOPATH para os experimentos subsequentes.
Estrutura Básica de um Sistema de Cache
Dados em cache precisam ser armazenados na memória para serem acessados rapidamente. Qual estrutura de dados deve ser usada para armazenar os itens de dados? Em geral, uma tabela hash é usada para armazenar os itens de dados, pois isso oferece melhor desempenho para acessar os dados. Na linguagem Go, não precisamos implementar nossa própria tabela hash porque o tipo embutido map já implementa uma tabela hash. Portanto, podemos armazenar diretamente os itens de dados em cache em um map.
Como o sistema de cache também suporta a limpeza de dados expirados, os itens de dados em cache devem ter um tempo de vida. Isso significa que os itens de dados em cache precisam ser encapsulados e salvos no sistema de cache. Para fazer isso, primeiro precisamos implementar o item de dados em cache. Crie um novo diretório cache no diretório GOPATH/src e crie um arquivo fonte cache.go:
package cache
import (
"encoding/gob"
"fmt"
"io"
"os"
"sync"
"time"
)
type Item struct {
Object interface{} // The actual data item
Expiration int64 // Lifespan
}
// Check if the data item has expired
func (item Item) Expired() bool {
if item.Expiration == 0 {
return false
}
return time.Now().UnixNano() > item.Expiration
}
No código acima, definimos uma estrutura Item que possui dois campos. Object é usado para armazenar objetos de dados de qualquer tipo, e Expiration armazena o tempo de expiração do item de dados. Também fornecemos um método Expired() para o tipo Item, que retorna um valor booleano indicando se o item de dados expirou. É importante notar que o tempo de expiração de um item de dados é um timestamp Unix medido em nanossegundos. Como determinamos se um item de dados expirou? É realmente muito simples. Registramos o tempo de expiração de cada item de dados, e o sistema de cache verifica periodicamente cada item de dados. Se o tempo de expiração de um item de dados for anterior ao tempo atual, o item de dados é removido do sistema de cache. Para fazer isso, usaremos o módulo time para implementar tarefas periódicas.
Com isso, agora podemos implementar a estrutura do sistema de cache. O código é o seguinte:
const (
// Flag for no expiration time
NoExpiration time.Duration = -1
// Default expiration time
DefaultExpiration time.Duration = 0
)
type Cache struct {
defaultExpiration time.Duration
items map[string]Item // Store cache data items in a map
mu sync.RWMutex // Read-write lock
gcInterval time.Duration // Expiration data cleaning interval
stopGc chan bool
}
// Clean expired cache data items
func (c *Cache) gcLoop() {
ticker := time.NewTicker(c.gcInterval)
for {
select {
case <-ticker.C:
c.DeleteExpired()
case <-c.stopGc:
ticker.Stop()
return
}
}
}
No código acima, implementamos a estrutura Cache, que representa a estrutura do sistema de cache. O campo items é um mapa usado para armazenar os itens de dados em cache. Como você pode ver, também implementamos o método gcLoop(), que agenda o método DeleteExpired() para ser executado periodicamente usando um time.Ticker. Um ticker criado usando time.NewTicker() enviará dados de seu canal ticker.C no intervalo gcInterval especificado. Podemos usar essa característica para executar periodicamente o método DeleteExpired().
Para garantir que a função gcLoop() possa terminar normalmente, ouvimos dados do canal c.stopGc. Se houver dados enviados para este canal, interrompemos a execução de gcLoop(). Observe também que definimos as constantes NoExpiration e DefaultExpiration, onde a primeira representa um item de dados que nunca expira e a última representa um item de dados com um tempo de expiração padrão. Como implementamos DeleteExpired()? Veja o código abaixo:
// Delete a cache data item
func (c *Cache) delete(k string) {
delete(c.items, k)
}
// Delete expired data items
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 você pode ver, o método DeleteExpired() é bastante simples. Só precisamos iterar por todos os itens de dados e excluir os expirados.
Implementando a Interface CRUD para o Sistema de Cache
Agora, podemos implementar a interface CRUD para o sistema de cache. Podemos adicionar dados ao sistema de cache usando as seguintes interfaces:
// Set cache data item, overwrite if the item exists
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,
}
}
// Set data item without lock operation
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,
}
}
// Get data item, also need to check if item has expired
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
}
// Add data item, returns error if item already exists
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
}
// Get data item
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
}
No código acima, implementamos as interfaces Set() e Add(). A principal diferença entre as duas é que a primeira sobrescreve o item de dados no sistema de cache se ele já existir, enquanto a última lança um erro se o item de dados já existir, impedindo que o cache seja incorretamente sobrescrito. Também implementamos o método Get(), que recupera o item de dados do sistema de cache. É importante notar que o verdadeiro significado da existência de um item de dados em cache é que o item existe e não expirou.
Em seguida, podemos implementar as interfaces de exclusão e atualização.
// Replace an existing data item
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
}
// Delete a data item
func (c *Cache) Delete(k string) {
c.mu.Lock()
c.delete(k)
c.mu.Unlock()
}
O código acima é autoexplicativo, então não entrarei em muitos detalhes.
Importação e Exportação do Sistema de Cache
Anteriormente, mencionamos que o sistema de cache suporta a importação de dados para um arquivo e o carregamento de dados de um arquivo. Agora, vamos implementar essa funcionalidade.
// Write cache data items to 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
}
// Save data items to a file
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()
}
// Read data items from 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
}
// Load cache data items from a file
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()
}
No código acima, o método Save() codifica os dados binários do cache usando o módulo gob e os escreve em um objeto que implementa a interface io.Writer. Por outro lado, o método Load() lê dados binários de um io.Reader e, em seguida, desserializa os dados usando o módulo gob. Essencialmente, aqui estamos serializando e desserializando os dados do cache.
Outras Interfaces do Sistema de Cache
Até agora, a funcionalidade de todo o sistema de cache foi concluída, e a maior parte do trabalho foi feito. Vamos concluir as tarefas finais.
// Return the number of cached data items
func (c *Cache) Count() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.items)
}
// Clear the cache
func (c *Cache) Flush() {
c.mu.Lock()
defer c.mu.Unlock()
c.items = map[string]Item{}
}
// Stop cleaning expired cache
func (c *Cache) StopGc() {
c.stopGc <- true
}
// Create a new cache system
func NewCache(defaultExpiration, gcInterval time.Duration) *Cache {
c := &Cache{
defaultExpiration: defaultExpiration,
gcInterval: gcInterval,
items: map[string]Item{},
stopGc: make(chan bool),
}
// Start the expiration cleanup goroutine
go c.gcLoop()
return c
}
No código acima, adicionamos vários métodos. Count() retornará o número de itens de dados armazenados em cache no sistema, Flush() limpará todo o sistema de cache e StopGc() impedirá que o sistema de cache limpe os itens de dados expirados. Finalmente, podemos criar um novo sistema de cache usando o método NewCache().
Até agora, todo o sistema de cache foi concluído. É bem simples, não é? Agora, vamos realizar alguns testes.
Testando o Sistema de Cache
Vamos escrever um programa de exemplo cujo código-fonte está localizado em ~/project/golang/src/cache/sample/sample.go. O conteúdo do programa é o seguinte:
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 for 10 seconds
time.Sleep(s)
// Now k1 should have been cleared
if v, found := c.Get("k1"); found {
fmt.Println("Found k1: ", v)
} else {
fmt.Println("Not found k1")
}
}
O código de exemplo é muito simples. Criamos um sistema de cache usando o método NewCache, com um período de limpeza de expiração de dados de 3 segundos e um tempo de expiração padrão de meia hora. Em seguida, definimos um item de dados "k1" com um tempo de expiração de 5 segundos. Após definir o item de dados, o recuperamos imediatamente e, em seguida, pausamos por 10 segundos. Quando recuperamos "k1" novamente, ele deveria ter sido limpo. O programa pode ser executado usando o seguinte comando:
cd ~/project/golang/src/cache
go mod init
go run sample/sample.go
A saída será a seguinte:
Found k1: hello labex
Not found k1
Resumo
Neste projeto, desenvolvemos um sistema de cache que permite adicionar, excluir, substituir e consultar objetos de dados. Ele também inclui a capacidade de excluir dados expirados. Se você está familiarizado com o Redis, pode estar ciente de sua capacidade de incrementar um valor numérico associado a uma chave. Por exemplo, se o valor de "key" for definido como "20", ele pode ser incrementado para "22" passando o parâmetro "2" para a interface Increment. Você gostaria de tentar implementar essa funcionalidade usando nosso sistema de cache?



