DEV Community

Cover image for Utilizando o pattern Heartbeats em Golang
Airton Lira junior
Airton Lira junior

Posted on

Utilizando o pattern Heartbeats em Golang

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 do time.Ticker.
  • task_test.go: Realiza um teste unitário da função presente em task.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",
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Importação das Dependências

package heartbeat

import (
    "context"
    "fmt"
    "time"
)
Enter fullscreen mode Exit fullscreen mode

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) {
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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ção return.
  • case <-beat.C: Se o ticker beat enviar um valor, a goroutine tenta enviar um struct vazio para o canal heartbeats utilizando um select com um default vazio.
  • case names <- dicionario[letra]: Se uma letra for recebida, a goroutine obtém o nome da letra correspondente a partir do dicionário dicionario, envia-o para o canal names, imprime a letra na tela utilizando o pacote fmt 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)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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

LinkedIn - Airton Lira Junior

Top comments (0)