Go Golang

23 mar, 2023

Utilizando o pattern Heartbeats em Golang

Publicidade

Durante minhas aventuras de equilibro entre data & software engineer, sempre pego algo um pouco diferente de GoLang para estudar o funcionamento e aplicar coisas mais complexas do alguns cursos e artigos tradicionais que encontro por ai. Nesse breve artigo vou relatar e demonstrar como implementei através Go Routines, time utilizando Ticker para simular o batimento (i’m alive) da aplicação, canais etc..

Não é novidade para muitos que é de suma importancia garantir que quem chama determinada função saiba se a função esta demorando, processando, esta travada, e dito isso surgiram varias outras terminologias como Trace, Metrics, conectividade etc.. que foram introduzidas em aplicações de monitoria que usam na maioria dos dos casos agents instalados nos servidores da aplicação que coletam métricas e enviam para interface no qual você saber todo (ou quase) estado da sua aplicação, entre essas ferramentas temos DataDog, NewRelic, Slack, Grafana, Jager etc..

Ta mas o que teremos aqui?

Como havia com objetivo de estudos e pensando em criar algo rapido e simples que abordase alguns conceitos de golang, criei uma aplicação relativamente simples no qual faz o papel do pattern heartbeats, ou seja, quem estiver me chamando recebe o resultado e ao mesmo tempo 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 aplicativa conceitualmente a nivel de particularidade de negocio, visto que uma simples implementação de um prometheus resolve esse caso (aplicação esta ativa? CPU, Memoria), mas não com feedback simultaneo e customizavel.

Hora do código!

A nivel de estrutura criei apenas 3 arquivos dentro do meu package com go mod:

  • dicionário.go: Vai conter um dicionario de nomes para a função fazer a busca.
  • task.go: É tarefa que contem a função de varrer os nomes do dicionário e ao mesmo tempo informar se ela esta ativa ou não via channel + beat do time.Ticker
  • task_test.go: Realiza um teste unitário da função presente no task.go para vermos tanto a resposta dos dados do dicionario como também o feedback de se a aplicação ainda esta Up!

Dicionario.go

Esta parte 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 acima, 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:


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:


Aqui tenho o meu pacote heartbeats que será responsavel 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:


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 caracter recebido.

Canais:


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 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çã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 caracter. 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


Bom 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 onde recebe recebe caracteres de um canal, envia nomes de letras para outro canal e emiti 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 presente 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 multiplos canais)