Implementando Heartbeats em Go para Monitoramento de Aplicações
Durante minhas aventuras de equilíbrio entre Data & Software Engineer, sempre busco algo um pouco diferente em GoLang para estudar, entender o funcionamento e aplicar em coisas mais complexas do que alguns cursos e artigos tradicionais básicos que encontro pela internet. Neste breve artigo, vou relatar e demonstrar como implementei através de Go Routines, o pacote time
utilizando Ticker
para simular o batimento ("i'm alive") da aplicação, além do uso de canais, etc.
Não é novidade para muitos que é de suma importância garantir que quem chama determinada função saiba se a função está demorando, processando, ou em lock. Dito isso, surgiram várias outras terminologias como Trace, Metrics, conectividade, etc., que foram introduzidas em aplicações de monitoramento que usam na maioria dos casos agentes instalados nos servidores da aplicação que coletam métricas e enviam para interfaces que visualizam todo (ou quase) o estado da sua aplicação. Entre essas ferramentas temos DataDog, NewRelic, Slack, Grafana, Jaeger, etc.
O Que Teremos Aqui?
Como estudo e pensando em criar algo rápido e simples que abordasse alguns conceitos mais avançados de Go, criei uma aplicação relativamente simples que faz uso do pattern heartbeats. Quem estiver me chamando recebe o resultado e, ao mesmo tempo, informações se ainda estou ativo ou não. Em um cenário mais avançado, isso pode ser interessante para customizar o que de fato é uma aplicação ativa a nível de alguma particularidade de negócio, visto que uma simples implementação de um Prometheus resolve esse caso (aplicação está ativa? CPU, Memória, goroutines abertas), mas não com feedback simultâneo e customizável.
Hora do Código!
A nível de estrutura, criei apenas três arquivos dentro do meu package com go mod
:
-
dicionario.go
: Contém um dicionário de nomes para a função fazer a busca. -
task.go
: Tarefa que contém a função de varrer os nomes do dicionário e, ao mesmo tempo, informar se ela está ativa ou não via channel + beat dotime.Ticker
. -
task_test.go
: Realiza um teste unitário da função presente emtask.go
para vermos tanto a resposta dos dados do dicionário como também o feedback de se a aplicação ainda está Up!
dicionario.go
Esta parte do código em Go está definindo uma variável chamada “dicionario” que é um mapa (map
) que associa caracteres do tipo rune
a strings
.
Cada entrada do mapa é uma chave (rune
) e um valor (string
). No exemplo abaixo, as chaves são letras minúsculas do alfabeto e os valores são nomes associados a cada letra. Por exemplo, a letra ‘a’ está associada ao nome “airton”, a letra ‘b’ está associada ao nome “bruno”, e assim por diante:
package heartbeat
var dicionario = map[rune]string{
'a': "airton",
'b': "bruno",
'c': "carlos",
'd': "daniel",
'e': "eduardo",
'f': "felipe",
'g': "gustavo",
}
task.go
Explico melhor abaixo após o código completo cada parte do código:
package heartbeat
import (
"context"
"fmt"
"time"
)
func ProcessingTask(
ctx context.Context, letras chan rune, interval time.Duration,
) (<-chan struct{}, <-chan string) {
heartbeats := make(chan struct{}, 1)
names := make(chan string)
go func() {
defer close(heartbeats)
defer close(names)
beat := time.NewTicker(interval)
defer beat.Stop()
for letra := range letras {
select {
case <-ctx.Done():
return
case <-beat.C:
select {
case heartbeats <- struct{}{}:
default:
}
case names <- dicionario[letra]:
lether := dicionario[letra]
fmt.Printf("Letra: %s \n", lether)
time.Sleep(3 * time.Second) // Simula um tempo de espera para vermos o hearbeats
}
}
}()
return heartbeats, names
}
Importação das Dependências
package heartbeat
import (
"context"
"fmt"
"time"
)
Aqui tenho o meu pacote heartbeat
que será responsável por implementar uma funcionalidade que envia “batimentos cardíacos” (“heartbeats”) em um intervalo de tempo específico, enquanto processa tarefas. Para isso, preciso do contexto (Gerenciamento de contexto), fmt
(para formatação de string) e time
para controle de tempo.
Definição Inicial da Função
func ProcessingTask (
ctx context.Context, letras chan rune, interval time.Duration,
) (<-chan struct{}, <-chan string) {
Esta é a definição da função ProcessingTask
que recebe um contexto ctx
, um canal de letras letras
(um canal que recebe caracteres Unicode) e um intervalo de tempo interval
como argumentos. A função retorna dois canais: um canal heartbeats
que envia um struct vazio a cada “batimento cardíaco” e um canal names
que envia o nome da letra correspondente a cada caractere recebido.
Canais
heartbeats := make(chan struct{}, 1)
names := make(chan string)
Estas duas linhas criam dois canais: heartbeats
é um canal de buffer com capacidade de um elemento e names
é um canal sem buffer.
Go Routine que Faz o Trabalho Pesado
go func()
defer close(heartbeats)
defer close(names)
beat := time.NewTicker(interval)
defer beat.Stop()
for letra := range letras {
select {
case <-ctx.Done():
return
case <-beat.C:
select {
case heartbeats <- struct{}{}:
default:
}
case names <- dicionario[letra]:
lether := dicionario[letra]
fmt.Printf("Letra: %s \n", lether)
time.Sleep(3 * time.Second) // Simula um tempo de espera para vermos o hearbeats
}
}
}()
return heartbeats, names
Esta é uma goroutine anônima (ou função anônima que é executada em uma nova thread) que executa a lógica principal da função ProcessingTask
. Ela utiliza um loop for-range
para ler caracteres do canal letras
. Dentro do loop, utiliza um select
para escolher uma ação a ser executada dentre as opções disponíveis:
-
case <-ctx.Done()
: Se o contexto for cancelado, a função encerra imediatamente, utilizando a instruçãoreturn
. -
case <-beat.C
: Se o tickerbeat
enviar um valor, a goroutine tenta enviar um struct vazio para o canalheartbeats
utilizando umselect
com umdefault
vazio. -
case names <- dicionario[letra]
: Se uma letra for recebida, a goroutine obtém o nome da letra correspondente a partir do dicionáriodicionario
, envia-o para o canalnames
, imprime a letra na tela utilizando o pacotefmt
e espera por três segundos antes de prosseguir para o próximo caractere. Essa espera simulada é para que possamos ver o envio dos “heartbeats”.
Por fim, a função retorna os canais heartbeats
e names
.
Testando a Aplicação
task_test.go
package heartbeat
import (
"context"
"fmt"
"testing"
"time"
)
func TestProcessingTask(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
letras := make(chan rune)
go func() {
defer close(letras)
for i := 'a'; i <= 'g'; i++ {
letras <- i
}
}()
heartbeats, words := ProcessingTask(ctx, letras, time.Second)
for {
select {
case <-ctx.Done():
return
case <-heartbeats:
fmt.Printf("Application Up! \n")
case letra, err := <-words:
if !err {
return
}
if _, notfound := dicionario[rune(letra[0])]; !notfound {
t.Errorf("Letra %s não encontrada", letra)
}
}
}
}
Aqui criei um teste unitário do Go para a função ProcessingTask
que foi explicada anteriormente. A função de teste TestProcessingTask
cria um contexto com um timeout de 20 segundos e um canal de caracteres Unicode (letras
). A goroutine anônima em seguida envia letras para o canal letras
. A função ProcessingTask
é então chamada com o contexto, o canal de caracteres Unicode e um intervalo de tempo. Ela retorna dois canais, um canal de batimento cardíaco e um canal de palavras.
Em seguida, a função de teste executa um loop infinito com um select
, que lê a partir de três canais: o contexto, o canal de batimentos cardíacos e o canal de palavras.
Se o contexto for cancelado, o loop de teste é encerrado. Se um batimento cardíaco for recebido, uma mensagem “Application Up!” é impressa na saída padrão. Se uma palavra for recebida, o teste verifica se a palavra está presente no dicionário de letras. Se não estiver presente, o teste falha e uma mensagem de erro é exibida.
Portanto, este teste unitário testa nossa função ProcessingTask
, que recebe caracteres de um canal, envia nomes de letras para outro canal e emite os “batimentos cardíacos” enquanto estiver executando em um contexto no qual utilizei um limite de tempo. Ahhh… e ele também verifica se os nomes das letras enviadas para o canal de palavras estão presentes no dicionário.
Minhas Conclusões
Este código em Go ilustra alguns conceitos importantes da linguagem Go e testes de unidade:
- Contexto
- Goroutines
- Canais
- Testes de unidade (utilizando
select
para monitorar múltiplos canais)
Projeto completo no meu GitHub: https://github.com/AirtonLira/heartbeatsGolang
Top comments (0)