Benchmark: API com gorilla mux usando goroutines vs sem goroutines

Já faz um certo tempo que eu queria dedicar algumas horas para testar um cenário onde os dados que uma request deveria apresentar fossem obtidos com goroutines vs sem goroutines.

Finalmente esse dia chegou, mas antes de apresentar os resultados, vamos construir juntos uma simples API onde vamos executar os testes para medir a performance.

O objetivo da request será obter o nome e a quantidade total de pedidos que uma pessoa já realizou.

Para não ter que envolver banco de dados, vamos criar duas variáveis contendo os dados que podemos retornar.

var (
    people = [][]string{
        []string{"1", "Tiago Temporin"},
        []string{"2", "João Silva"},
        []string{"3", "Mateus Cardoso"},
        []string{"4", "Maria Lina"},
        []string{"5", "Camila Manga"},
        []string{"6", "Joice Santos"},
        []string{"7", "Lucas Leal"},
        []string{"8", "Vanessa da Terra"},
        []string{"9", "Mateus de Morais"},
        []string{"10", "Maria Luiza"},
    }

    orders = [][]string{
        []string{"1", "5"},
        []string{"2", "10"},
        []string{"3", "0"},
        []string{"4", "0"},
        []string{"5", "2"},
        []string{"6", "9"},
        []string{"7", "3"},
        []string{"8", "15"},
        []string{"9", "3"},
        []string{"10", "7"},
    }
)

Ambas as variáveis são um array de array, onde a primeira posição é o “ID” e a segunda o valor que queremos obter.

Para conseguir obter o valor com base no ID que vamos receber na request, vamos criar duas funções. Uma para retornar o nome da pessoa e outra para retornar a quantidade de compras.

func getFullName(id string) (name string) {
    for _, row := range people {
        if id == row[0] {
            name = row[1]
        }
    }

    return
}

func getTotalOrders(id string) (qtd string) {
    for _, row := range orders {
        if id == row[0] {
            qtd = row[1]
        }
    }

    return
}

Como podemos ver, após encontrar o valor que a request solicita eu não adicionei um break. Isso foi feito de forma intencional, para poder gerar um pouco mais de trabalho no processamento da request.

Agora que já temos os dados e as funções para buscarem o nome e a quantidade de pedidos, vamos para a API.

Na API teremos dois endpoints. Um que irá utilizar goroutines, com o path /cc/{id}, e outro que não irá utilizar, o qual terá o path /sc/{id}.

A busca pelos dados ficará em um middleware, que com base na URL decidirá se usa ou não goroutine para buscar os dados.

Por fim, para retornar a response, vamos criar uma função chamada NameOrders.

Com tudo definido, vamos codar a função que servirá de middleware na nossa API.

func middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
        var (
            vars   = mux.Vars(r)
            name   string
            orders string
        )

        if strings.Contains(r.URL.Path, "cc") {
            cname := make(chan string, 1)
            corders := make(chan string, 1)

            wg := sync.WaitGroup{}
            wg.Add(2)

            go func() {
                cname <- getFullName(vars["id"])
                wg.Done()
            }()

            go func() {
                corders <- getTotalOrders(vars["id"])
                wg.Done()
            }()

            wg.Wait()

            name = <-cname
            orders = <-corders
        } else {
            name = getFullName(vars["id"])
            orders = getTotalOrders(vars["id"])
        }

        ctx := context.WithValue(r.Context(), "name", name)
        ctx = context.WithValue(ctx, "orders", orders)
        r = r.WithContext(ctx)

        next.ServeHTTP(rw, r)
    })
}

Agora que temos nosso middleware, vamos implementar nossa função que irá tratar a response.

func NameOrders(w http.ResponseWriter, r *http.Request) {
    name := r.Context().Value("name")
    orders := r.Context().Value("orders")

    w.WriteHeader(http.StatusOK)
    fmt.Fprintf(w, "Olá %s, seu total de pedidos é de %s\n", name, orders)
}

Por último, vamos escrever nossa função principal para levantar essa API.

func main() {
    r := mux.NewRouter()
    r.Use(middleware)

    r.HandleFunc("/cc/{id}", NameOrders)
    r.HandleFunc("/sc/{id}", NameOrders)

    http.ListenAndServe(":8000", r)
}

Para fins de comparação, o import do nosso arquivo ficou assim:

import (
    "context"
    "fmt"
    "net/http"
    "strings"
    "sync"

    "github.com/gorilla/mux"
)

Tudo pronto, agora é só mandar um go run main.go e temos uma API em pé e pronta para ser testada.

Para realizar os testes vou usar um programa chamado wrk, que nada mais é do que uma ferramenta para fazer benchmark em APIs.

Abaixo vou deixar os prints dos 3 testes que executei em cada um dos endpoints.

wrk -t12 -c400 -d30s http://localhost:8000/sc/9
wrk -t12 -c400 -d30s http://localhost:8000/cc/9
wrk -t12 -c400 -d30s http://localhost:8000/sc/5
wrk -t12 -c400 -d30s http://localhost:8000/cc/5
wrk -t12 -c400 -d30s http://localhost:8000/sc/2
wrk -t12 -c400 -d30s http://localhost:8000/cc/2

Como podemos ver nos prints, o endpoint que não utiliza goroutines atendeu uma média de aproximadamente 842.278 requests em 30s, enquanto o endpoint que utiliza goroutines conseguiu atender uma média de aproximadamente 786.405, ou seja, quase 8% menos.

Embora 8% pareça pouco, quando olhamos a diferença absoluta, 55.873 requests a mais, o que da cerca 931 requests por segundo, é um ganho e tanto.

Testou ai também? Foi igual? Ficou com dúvida? Deixe nos comentários.

Até a próxima!


Subscreva

Fique por dentro de tudo o que acontece no mundo Go.

Deixe uma resposta