LocalStack e Docker: Como aumentar a produtividade no desenvolvimento LOCAL sem custos

rflpazini

Rafael Pazini

Posted on October 8, 2024

LocalStack e Docker: Como aumentar a produtividade no desenvolvimento LOCAL sem custos

Desenvolver aplicações que interagem com serviços da AWS pode ser um desafio, especialmente quando se trata de configurar e testar recursos sem aumentar em custos ou enfrentar limitações de conectividade. O LocalStack surge como uma solução poderosa para emular serviços da AWS localmente, permitindo que você desenvolva e teste seu código de forma eficiente.

Neste post, vamos explorar como configurar o LocalStack e integrá-lo com aplicações em Golang, fornecendo exemplos práticos que beneficiarão desde desenvolvedores seniores até estagiários.

O que é o LocalStack?

O LocalStack é uma plataforma que simula serviços da AWS em sua máquina local. Ele permite que você desenvolva e teste funcionalidades que dependem de serviços como S3, DynamoDB, SQS, Lambda e muitos outros, sem precisar acessar a nuvem real da AWS.

Podemos dizer que suas maiores vantagens são as seguintes:

  • Custo ZERO: Evita custos associados ao uso dos serviços reais da AWS durante o desenvolvimento.
  • Desenvolvimento offline: Você pode trabalhar sem conexão com a internet.
  • Ciclo de feedback rápido: Teste suas funcionalidades localmente, acelerando o desenvolvimento.
  • Ambiente controlado: Simule diferentes cenários sem afetar ambientes de produção ou teste.

Configurando o ambiente

Vamos começar já "colocando a mão na massa". Para isso, iremos construir uma aplicação que cria usuários de forma assíncrona usando DynamoDB e SQS. Iremos utilizar o AWS SDK e o LocalStack, dessa forma o mesmo código funciona para o mundo real e para rodar localmente nossa aplicação.

Antes de começarmos, certifique-se de que o Docker e o Go estão instalados corretamente em sua máquina. Além disso, exporte as credenciais de acesso AWS (mesmo que sejam fictícias), já que o SDK da AWS requer essas informações.

export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
Enter fullscreen mode Exit fullscreen mode

Estrutura do projeto

Para manter nosso projeto organizado, seguiremos uma estrutura que separa claramente as responsabilidades:

├── cmd
│   ├── service
│   │   └── main.go
│   └── worker
│       └── main.go
├── internal
│   ├── config
│   │   └── config.go
│   └── server
│       └── server.go
├── pkg
│   ├── api
│   │   └── handlers
│   │       └── user.go
│   ├── aws
│   │   ├── client.go
│   │   ├── dynamo.go
│   │   └── sqs.go
│   └── service
│       ├── models
│       │   └── user.go
│       └── user.go
├── compose.yml
├── go.mod
└── go.sum
Enter fullscreen mode Exit fullscreen mode

Os arquivos do projeto estão disponíveis no github

Explicação da Estrutura:

  • cmd/: Contém os executáveis da aplicação.
  • service/: O servidor HTTP.
  • worker/: O consumidor SQS.
  • internal/: Código interno não exposto para outros módulos.
  • config/: Gerencia a configuração AWS.
  • server/: Configuração do servidor e inicialização dos serviços AWS.
  • pkg/: Pacotes reutilizáveis.
  • api/handlers/: Handlers das rotas HTTP.
  • aws/: Interações com os serviços AWS.
  • service/: Lógica de negócios e modelos de dados.
  • compose.yml: Configuração do LocalStack.
  • go.mod e go.sum: Gerenciamento de dependências Go.

Configurando o LocalStack e a Aplicação

1- Criando o compose.yml

Como sempre, utilizaremos nosso amigo Docker para facilitar e não termos que instalar nada além de rodar o comando do compose para subir o LocalStack:

# compose.yml
services:
  localstack:
    image: localstack/localstack:latest
    container_name: localstack
    ports:
      - "4566:4566"
    environment:
      - SERVICES=dynamodb,sqs
      - DEBUG=1
Enter fullscreen mode Exit fullscreen mode

2- Definindo o model

Crie o arquivo user.go dentro de pkg/service/models/:

package models

type User struct {
    ID       string `json:"id"`
    Name     string `json:"name"`
    Email    string `json:"email"`
    Password string `json:"password,omitempty"` // Em um cenário real, nunca armazenar senhas em texto :D
    Address  string `json:"address"`
    Phone    string `json:"phone"`
}
Enter fullscreen mode Exit fullscreen mode

3- Configurando a conexão com a AWS

Crie o arquivo config.go dentro de internal/config/. Ele será o singleton que carregará as configs para nosso LocalStack. Pense no singleton como um gerente de loja que mantém a mesma estratégia para todas as filiais. Não importa quantas lojas (clientes) existam, a estratégia (configuração) é consistente.:

package config

import (
    "context"
    "log"
    "sync"

    "github.com/aws/aws-sdk-go-v2/aws"
    awsConfig "github.com/aws/aws-sdk-go-v2/config"
)

const (
        UsersTable = "users"
        UsersQueue = "users_queue"
)

var (
    cfg        aws.Config
    once       sync.Once
    QueueURL   string
)

func GetAWSConfig() aws.Config {
    once.Do(func() {
        var err error
        cfg, err = awsConfig.LoadDefaultConfig(context.Background(),
            awsConfig.WithRegion("us-east-1"),
        )
        if err != nil {
            log.Fatalf("error during AWS config: %v", err)
        }
    })
    return cfg
}
Enter fullscreen mode Exit fullscreen mode

4- Inicializando os clients

Crie o arquivo client.go em pkg/aws/. Ele será responsável por passar as configs que carregamos para os clients dos serviços da AWS que estamos emulando:

package aws

import (
    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/service/dynamodb"
    "github.com/aws/aws-sdk-go-v2/service/sqs"
)

var (
    DynamoDBClient *dynamodb.Client
    SQSClient      *sqs.Client
)

func InitClients(cfg aws.Config) {
    localstackEndpoint := "http://localhost:4566"

    DynamoDBClient = dynamodb.NewFromConfig(cfg, func(o *dynamodb.Options) {
        o.BaseEndpoint = aws.String(localstackEndpoint)
    })

    SQSClient = sqs.NewFromConfig(cfg, func(o *sqs.Options) {
        o.BaseEndpoint = aws.String(localstackEndpoint)
    })
}

Enter fullscreen mode Exit fullscreen mode

5- Implementando funções que utilizaremos com os clients

Agora que já fizemos o loading das configurações para os clients dos serviços, chegou a hora de implementar os métodos que serão utilizados para criar fila, publicar mensagem e criar tabela.

Começaremos criando o sqs.go dentro do package pkg/aws/, onde teremos duas funções, a CreateQueue responsável por criar uma fila e a SendMessage responsável por mandar mensagens para a fila que criamos:

package aws

import (
    "context"
    "log"

    "github.com/aws/aws-sdk-go-v2/service/sqs"
)

func CreateQueue(queueName string) (string, error) {
    result, err := SQSClient.CreateQueue(context.Background(), &sqs.CreateQueueInput{
        QueueName: &queueName,
    })
    if err != nil {
        return "", err
    }
    return *result.QueueUrl, nil
}

func SendMessage(ctx context.Context, queueUrl, messageBody string) error {
    log.Printf("Sending message with body: %s to %s", messageBody, queueUrl)
    _, err := SQSClient.SendMessage(ctx, &sqs.SendMessageInput{
        QueueUrl:    &queueUrl,
        MessageBody: &messageBody,
    })
    return err
}
Enter fullscreen mode Exit fullscreen mode

Se você reparar bem, eu preferi criar as funções bem genéricas.

  • CreateQueue: ela vai receber um nome de uma fila e criará esta fila com o nome que recebeu.
  • SendMessage: recebe a URL da fila onde deve publicar a mensagem e a mensagem que deve ser publicada. Dessa forma temos funções que podem ser reutilizadas sempre que necessário dentro de nosso código.

Agora vamos criar o dynamo.go dentro do mesmo package pkg/aws, assim fica tudo centralizado dentro de um mesmo pacote o que é referente aos serviços da AWS.

package aws

import (
    "context"
    "errors"

    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/service/dynamodb"
    "github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)

func CreateTable(tableName string) error {
    _, err := DynamoDBClient.CreateTable(context.Background(), &dynamodb.CreateTableInput{
        TableName: aws.String(tableName),
        AttributeDefinitions: []types.AttributeDefinition{
            {
                AttributeName: aws.String("ID"),
                AttributeType: types.ScalarAttributeTypeS,
            },
        },
        KeySchema: []types.KeySchemaElement{
            {
                AttributeName: aws.String("ID"),
                KeyType:       types.KeyTypeHash,
            },
        },
        BillingMode: types.BillingModePayPerRequest,
    })
    if err != nil {
        var resourceInUseException *types.ResourceInUseException
        if errors.As(err, &resourceInUseException) {
            return nil
        }
        return err
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Aqui continuamos no mesmo conceito, criando uma função genérica para ser reutilizada caso precise em outro ponto do código. No dynamo temos apenas que criar uma função que criará a tabela:

  • CreateTable: recebe um nome de uma tabela e cria essa tabela.

6- Implementando o service

Primeiro vamos criar a entidade que User onde teremos as informações do usuário. Para isso crie um arquivo user.go dentro do package pkg/service/model que é onde ficarão todos os models de nossa aplicação.

package models

type User struct {
    ID       string `json:"id"`
    Name     string `json:"name"`
    Email    string `json:"email"`
    Password string `json:"password"` // Em um cenário real, nunca armazenar senhas em texto :D
    Address  string `json:"address"`
    Phone    string `json:"phone"`
}
Enter fullscreen mode Exit fullscreen mode

Agora vamos para o service que será responsável por cuidar das regras de negócio relacionadas ao User. Então vamos criar o user.go dentro do package pkg/service.

Teremos 3 funções dentro desse arquivo:

  • CreateUser: que será responsável por receber um novo User, validar se existe algum usuário com o mesmo email já salvo no DB e caso não exista, salvar um novo user no DB.
  • GetUserByEmail: busca pelo User baseado no email que ele recebeu como parâmetro
  • GetAllUsers: retorna todos os Users salvos no DB.

O código dela fica assim:

package service

import (
    "context"
    "errors"
    "fmt"

    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/service/dynamodb"
    "github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
    "github.com/google/uuid"
    "github.com/rflpazini/localstack/internal/config"
    awsClient "github.com/rflpazini/localstack/pkg/aws"
    "github.com/rflpazini/localstack/pkg/service/models"
)

func CreateUser(ctx context.Context, user *models.User) error {
    existingUser, err := GetUserByEmail(ctx, user.Email)
    if err == nil && existingUser != nil {
        return errors.New("email is already in use by another user")
    } else if err != nil && err.Error() != "user not found" {
        return fmt.Errorf("failed to verify if email is already in use: %w", err)
    }

    user.ID = uuid.NewString()

    item := map[string]types.AttributeValue{
        "ID":       &types.AttributeValueMemberS{Value: user.ID},
        "Name":     &types.AttributeValueMemberS{Value: user.Name},
        "Email":    &types.AttributeValueMemberS{Value: user.Email},
        "Password": &types.AttributeValueMemberS{Value: user.Password},
        "Address":  &types.AttributeValueMemberS{Value: user.Address},
        "Phone":    &types.AttributeValueMemberS{Value: user.Phone},
    }

    _, err = awsClient.DynamoDBClient.PutItem(context.Background(), &dynamodb.PutItemInput{
        TableName: aws.String(config.UsersTable),
        Item:      item,
    })
    if err != nil {
        return fmt.Errorf("failed to create user: %w", err)
    }

    return nil
}

func GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
    result, err := awsClient.DynamoDBClient.Scan(ctx, &dynamodb.ScanInput{
        TableName:        aws.String(config.UsersTable),
        FilterExpression: aws.String("Email = :email"),
        ExpressionAttributeValues: map[string]types.AttributeValue{
            ":email": &types.AttributeValueMemberS{Value: email},
        },
    })
    if err != nil {
        return nil, fmt.Errorf("failed to scan table: %w", err)
    }

    if len(result.Items) == 0 {
        return nil, errors.New("user not found")
    }

    item := result.Items[0]
    user := &models.User{
        ID:      item["ID"].(*types.AttributeValueMemberS).Value,
        Name:    item["Name"].(*types.AttributeValueMemberS).Value,
        Email:   item["Email"].(*types.AttributeValueMemberS).Value,
        Address: item["Address"].(*types.AttributeValueMemberS).Value,
        Phone:   item["Phone"].(*types.AttributeValueMemberS).Value,
    }

    return user, nil
}

func GetAllUsers() ([]*models.User, error) {
    result, err := awsClient.DynamoDBClient.Scan(context.Background(), &dynamodb.ScanInput{
        TableName: aws.String(config.UsersTable),
    })
    if err != nil {
        return nil, fmt.Errorf("failed to retrieve all users: %w", err)
    }

    if len(result.Items) == 0 {
        return nil, errors.New("no users found")
    }

    users := make([]*models.User, 0)

    for _, item := range result.Items {
        user := &models.User{
            ID:      item["ID"].(*types.AttributeValueMemberS).Value,
            Name:    item["Name"].(*types.AttributeValueMemberS).Value,
            Email:   item["Email"].(*types.AttributeValueMemberS).Value,
            Address: item["Address"].(*types.AttributeValueMemberS).Value,
            Phone:   item["Phone"].(*types.AttributeValueMemberS).Value,
        }
        users = append(users, user)
    }

    return users, nil
}
Enter fullscreen mode Exit fullscreen mode

7- Implementando o handler

Crie os handlers que serão responsáveis por receber as requisições HTTP e interagir com os serviços.

Teremos 2 funções nesse handler do User:

  • GetUser: ele vai listar todos os usuários e caso receba o query param email buscará pelo usuário solicitado.
  • CreateUser: irá publicar na fila um usuário novo com base dados recebidos no request. Essa será uma operação async.
package handlers

import (
    "encoding/json"
    "net/http"

    "github.com/labstack/echo/v4"
    "github.com/rflpazini/localstack/internal/config"
    awsClient "github.com/rflpazini/localstack/pkg/aws"
    "github.com/rflpazini/localstack/pkg/service"
    "github.com/rflpazini/localstack/pkg/service/models"
)

func GetUser(c echo.Context) error {
    ctx := c.Request().Context()

    email := c.QueryParam("email")
    if email == "" {
        users, err := service.GetAllUsers()
        if err != nil {
            return err
        }
        return c.JSON(http.StatusOK, users)
    }

    user, err := service.GetUserByEmail(ctx, email)
    if err != nil {
        return c.JSON(http.StatusNotFound, err.Error())
    }

    return c.JSON(http.StatusOK, user)
}

func CreateUser(c echo.Context) error {
    ctx := c.Request().Context()

    user := new(models.User)
    if err := c.Bind(user); err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
    }

    message, err := json.Marshal(user)
    if err != nil {
        return c.JSON(http.StatusInternalServerError, err.Error())
    }

    err = awsClient.SendMessage(ctx, config.QueueURL, string(message))
    if err != nil {
        return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
    }

    return c.NoContent(http.StatusCreated)
}
Enter fullscreen mode Exit fullscreen mode

8- Configurando o server

Crie o servidor HTTP e configure as rotas em internal/server/server.go:

package server

import (
    "log"

    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/labstack/echo/v4"
    "github.com/rflpazini/localstack/internal/config"
    "github.com/rflpazini/localstack/pkg/api/handlers"
    awsClients "github.com/rflpazini/localstack/pkg/aws"
)

func Start(cfg aws.Config) {
    e := echo.New()
    awsClients.InitClients(cfg)

    initDependencies()

    e.POST("/user", handlers.CreateUser)
    e.GET("/user", handlers.GetUser)

    e.Logger.Fatal(e.Start(":8080"))
}

func initDependencies() {
    err := awsClients.CreateTable(config.UsersTable)
    if err != nil {
        log.Printf("create table error: %v", err)
    } else {
        log.Println("table created")
    }

    queueURL, err := awsClients.CreateQueue(config.UsersQueue)
    if err != nil {
        log.Printf("create queue error: %v", err)
    } else {
        config.QueueURL = queueURL
        log.Println("sqs queue created")
    }
}
Enter fullscreen mode Exit fullscreen mode

Aqui temos duas funções, o Start e o initDependencies:

  • Start: inicia o servidor HTTP e registra as rotas. Além de chamar o initDependencies
  • initDependencies: inicia os serviços da AWS criando a tabela e a fila que precisamos para rodar nosso aplicativo.

9- Configurando o worker e o main

Dentro do package cmd criaremos duas pastas. Uma chamada service e outra worker.

A service terá o main.go será responsável por carregar as configurações e chamar nosso Start do server.

package main

import (
    "github.com/rflpazini/localstack/internal/config"
    "github.com/rflpazini/localstack/internal/server"
)

func main() {
    cfg := config.GetAWSConfig()
    server.Start(cfg)
}
Enter fullscreen mode Exit fullscreen mode

O worker será a aplicação que consumirá as mensagens da fila. Lembra que criamos um service para salvar o usuário async? É com o worker que vamos consumir e salvar esse usuário no DB.

package main

import (
    "context"
    "encoding/json"
    "log"
    "time"

    "github.com/rflpazini/localstack/internal/config"
    "github.com/rflpazini/localstack/pkg/aws"
    "github.com/rflpazini/localstack/pkg/service"
    "github.com/rflpazini/localstack/pkg/service/models"

    "github.com/aws/aws-sdk-go-v2/service/sqs"
)

const (
    userQueueName = "users_queue"
)

func main() {
    ctx := context.Background()
    cfg := config.GetAWSConfig()
    aws.InitClients(cfg)

    queueURL := "http://localhost:4566/000000000000/" + userQueueName

    for {
        messages, err := aws.SQSClient.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
            QueueUrl:            &queueURL,
            MaxNumberOfMessages: 10,
            WaitTimeSeconds:     5,
        })
        if err != nil {
            log.Printf("Erro ao receber mensagens: %v", err)
            time.Sleep(5 * time.Second)
            continue
        }

        for _, msg := range messages.Messages {
            var user models.User
            err := json.Unmarshal([]byte(*msg.Body), &user)
            if err != nil {
                log.Printf("Erro ao desserializar mensagem: %v", err)
                continue
            }

            err = service.CreateUser(ctx, &user)
            if err != nil {
                log.Printf("Create user error: %v", err)
            }

            _, err = aws.SQSClient.DeleteMessage(ctx, &sqs.DeleteMessageInput{
                QueueUrl:      &queueURL,
                ReceiptHandle: msg.ReceiptHandle,
            })
            if err != nil {
                log.Printf("Erro ao deletar mensagem: %v", err)
            }
        }

        time.Sleep(1 * time.Second)
    }
}
Enter fullscreen mode Exit fullscreen mode

Ufa, terminamos a aplicação 😅

Executando a Aplicação

Bora rodar tudo isso e ver como ficou nosso app. A primeira coisa que devemos fazer, é subir o compose para iniciar o LocalStack:

docker compose up -d 
Enter fullscreen mode Exit fullscreen mode
[+] Running 1/1
 ✔ Container localstack  Started    
Enter fullscreen mode Exit fullscreen mode

Caso você tenha dúvida se o container esta ou não rodando, basta usar docker ps e ver se o container com a imagem do localstack aparece :)

Com o container do local stack rodando, vamos iniciar nossa aplicação e o worker.

Servidor & Worker

Primeiro rode o servidor, pois ele irá criar tanto a tabela quanto a fila que precisamos para que tudo funcione corretamente:

go run cmd/service/main.go
Enter fullscreen mode Exit fullscreen mode

Com o servidor rodando, em uma nova janela de terminal, rode o worker que irá consumir nossa fila:

go run cmd/worker/main.go
Enter fullscreen mode Exit fullscreen mode

Pronto, estamos com a aplicação e o worker rodando simultaneamente!

Testando as Funcionalidades

1- Registrando um Novo Usuário de Forma Assíncrona

Imagine que você está fazendo um pedido em um restaurante movimentado. Você faz o pedido (envia a requisição), o garçom anota e passa para a cozinha (fila SQS). Enquanto isso, você aguarda na mesa, e a comida é preparada e servida (processamento assíncrono).

Envie uma solicitação para registrar um novo usuário:

curl --location 'http://localhost:8080/user' \
--header 'Content-Type: application/json' \
--data-raw '{
    "name": "Carlos Silva",
    "email": "carlos@example.com",
    "password": "senha123",
    "address": "Rua A, 123",
    "phone": "123456789"
}'
Enter fullscreen mode Exit fullscreen mode

Você receberá um status response 201:

HTTP/1.1 201 Created
Enter fullscreen mode Exit fullscreen mode

Observe o console onde o worker da fila SQS está sendo executado. Você deve ver uma saída indicando que o usuário foi criado:
2024/10/08 11:01:58 creating user: carlos@example.com

2- Verificando a criação do usuário

Recupere as informações do usuário para verificar se ele foi criado:

curl --location 'http://localhost:8080/user?email=carlos%40example.com'
Enter fullscreen mode Exit fullscreen mode

Você receberá a seguinte resposta, caso ele tenha sido salvo com sucesso:

{
     "id": "2a32193a-bcd6-4d8f-87dd-64e65f8a8f22",
     "name": "Carlos Souza",
     "email": "carlos@example.com",
     "address": "Rua Central, 456",
     "phone": "999888777"
}
Enter fullscreen mode Exit fullscreen mode

Nesse mesmo endpoint se não colocarmos o email do usuário, vamos receber toda a base de volta. Você pode testar isso cadastrando vários usuários e fazendo o request:

curl --location 'http://localhost:8080/user'
Enter fullscreen mode Exit fullscreen mode

Cadastrei um usuário com meu nome para testarmos:

[
     {
          "id": "bdccfced-000f-4daf-82cc-712a8f4af182",
          "name": "Rafael Pazini",
          "email": "rflpazini@example.com",
          "address": "Rua A, 123",
          "phone": "123456789"
     },
     {
          "id": "2a32193a-bcd6-4d8f-87dd-64e65f8a8f22",
          "name": "Carlos Souza",
          "email": "carlos@example.com",
          "address": "Rua Central, 456",
          "phone": "999888777"
     }
]
Enter fullscreen mode Exit fullscreen mode

Considerações Finais

Neste guia, construímos uma aplicação Go que cria usuários de forma assíncrona usando DynamoDB e SQS, tudo isso localmente graças ao LocalStack em um contêiner Docker. Implementamos os handlers e serviços relacionados aos usuários, tornando a aplicação completa e funcional. Utilizamos analogias do dia a dia para facilitar a compreensão dos conceitos, como comparar a fila SQS a um garçom que recebe pedidos e os repassa para a cozinha.

Por que isso é importante?

Desenvolver e testar serviços AWS localmente com o LocalStack nos permite economizar tempo e recursos, além de facilitar o processo de desenvolvimento. É como ter um laboratório onde podemos experimentar e ajustar nossa aplicação antes de lançá-la no ambiente real.

O que aprendemos?

  • Como configurar o LocalStack em um contêiner Docker.
  • Como criar uma aplicação Go que interage com DynamoDB e SQS.
  • Como implementar o processamento assíncrono de mensagens.
  • Como desenvolver os handlers e serviços relacionados aos usuários.

Próximos passos:

Caso você queira se desafiar, fica aqui uma lição de casa para deixar a aplicação ainda mais robusta e próxima do mundo real:

  • Implementar autenticação e segurança.
  • Adicionar mais funcionalidades, como atualização e exclusão de usuários.
  • Integrar outros serviços da AWS conforme necessário.

É isso galera, espero que vocês gostem e deixem os comentários caso surja alguma dúvida!

Happy coding! 👨🏼‍💻

💖 💪 🙅 🚩
rflpazini
Rafael Pazini

Posted on October 8, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related