LocalStack e Docker: Como aumentar a produtividade no desenvolvimento LOCAL sem custos
Rafael Pazini
Posted on October 8, 2024
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
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
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
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"`
}
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
}
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)
})
}
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
}
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
}
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"`
}
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 novoUser
, 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 peloUser
baseado no email que ele recebeu como parâmetro -
GetAllUsers
: retorna todos osUsers
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
}
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)
}
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")
}
}
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)
}
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)
}
}
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
[+] Running 1/1
✔ Container localstack Started
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
Com o servidor rodando, em uma nova janela de terminal, rode o worker que irá consumir nossa fila:
go run cmd/worker/main.go
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"
}'
Você receberá um status response 201:
HTTP/1.1 201 Created
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'
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"
}
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'
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"
}
]
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! 👨🏼💻
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
October 8, 2024