12 Factor App + TDD

Construindo Aplicações Robustas e Escaláveis

Rafael Pazini

Foto do Palestrante
  • Pós-graduando em IA & Big Data - USP
  • Formado em Ciência da Computação - UNIP
  • Engenheiro de software com mais de 10 anos de experiência em desenvolvimento de aplicações backend de alta performance.
  • Atualmente na PlutoTV como Senior Software Engineer.
  • Nas horas vagas gosto de tirar fotos e brincar com as impressoras 3D

O que é 12 Factor App?

  • Conjunto de práticas para construir aplicações SaaS escaláveis e mantíveis
  • Criado por desenvolvedores da Heroku
  • Promove melhores práticas de desenvolvimento e operação de software

Os 12 Fatores

  • 1. Codebase
  • 2. Dependencies
  • 3. Config
  • 4. Backing Services
  • 5. Build, Release, Run
  • 6. Processes
  • 7. Port Binding
  • 8. Concurrency
  • 9. Disposability
  • 10. Dev/Prod Parity
  • 11. Logs
  • 12. Admin Processes

1. Codebase

Uma base de código rastreada em controle de versão, muitas implantações

  • Todo o código de uma aplicação deve estar em um único repositório de controle de versão.
  • Cada commit pode ser implantado em múltiplos ambientes (desenvolvimento, teste, produção).
  • Facilita a gestão de versões e a colaboração entre desenvolvedores.

git init
git add .
git commit -m "Initial commit"
                

2. Dependencies

Declare e isole dependências

  • Todas as dependências da aplicação devem ser explicitamente declaradas e gerenciadas.
  • Facilita a replicação do ambiente de desenvolvimento e produção, garantindo consistência.

// go.mod
module myapp

go 1.22

require (
    github.com/labstack/echo/v4 v4.6.3
)
                

go mod tidy
                

3. Config

Armazene a configuração no ambiente

  • Configurações, como credenciais de banco de dados ou chaves de API, devem ser armazenadas em variáveis de ambiente.
  • Aumenta a segurança e facilita a mudança de configurações entre diferentes ambientes.

package main

import (
    "fmt"
    "os"
)

func main() {
    dbURL := os.Getenv("DATABASE_URL")
    fmt.Println("Database URL:", dbURL)
}
                

export DATABASE_URL="postgres://user:password@localhost/db"
go run main.go
                

4. Backing Services

Trate serviços de apoio como recursos anexados

  • Serviços de apoio, como bancos de dados, sistemas de fila, cache, etc., devem ser tratados como recursos anexados que podem ser facilmente substituídos.
  • Aumenta a portabilidade e facilita a troca de serviços de apoio.

package main

import (
    "fmt"
    "os"
)

func main() {
    redisURL := os.Getenv("REDIS_URL")
    fmt.Println("Redis URL:", redisURL)
}
                

export REDIS_URL="redis://localhost:6379"
go run main.go
                

5. Build, Release, Run

Separe estritamente os estágios de construção e execução

  • O processo de implantação deve ser dividido em três estágios: build, release, e run.
    • Build: Converte o código-fonte em um executável.
    • Release: Combina o executável com a configuração específica do ambiente.
    • Run: Executa a aplicação no ambiente.
  • Garante que a mesma versão do código seja testada e implantada em produção.

# Build stage
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp

# Run stage
FROM alpine:latest
WORKDIR /root/
COPY --from=builder /app/myapp .
CMD ["./myapp"]
                

6. Processes

Execute a aplicação como um ou mais processos sem estado

  • A aplicação deve ser executada como um ou mais processos que não mantêm estado persistente entre as execuções.
  • Qualquer estado deve ser armazenado em um serviço de apoio.
  • Facilita a escalabilidade e a recuperação de falhas.

package main

import (
    "github.com/labstack/echo/v4"
)

func main() {
    e := echo.New()
    e.GET("/ping", func(c echo.Context) error {
        return c.String(200, "pong")
    })
    e.Start(":8080")
}
                

7. Port Binding

Exporte serviços via ligação de porta

  • A aplicação deve ser auto-suficiente e expor seus serviços via ligação de porta, sem depender de servidores web externos.
  • Simplifica a configuração e a implantação.

package main

import (
    "github.com/labstack/echo/v4"
)

func main() {
    e := echo.New()
    e.GET("/ping", func(c echo.Context) error {
        return c.String(200, "pong")
    })
    e.Start(":8080")
}
                

8. Concurrency

Escale por meio do modelo de processo

  • A aplicação deve ser projetada para escalar horizontalmente, executando múltiplas instâncias (processos) em paralelo.
  • Facilita a escalabilidade e melhora a performance.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
      - name: myapp
        image: myapp:latest
        ports:
        - containerPort: 8080
                

9. Disposability

Maximize a robustez com inicialização rápida e encerramento gracioso

  • A aplicação deve iniciar e parar rapidamente, suportando encerramento gracioso (graceful shutdown) para evitar perda de dados.
  • Melhora a robustez e facilita o gerenciamento de processos.

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "time"

    "github.com/labstack/echo/v4"
)

func main() {
    e := echo.New()
    e.GET("/ping", func(c echo.Context) error {
        return c.String(200, "pong")
    })

    go func() {
        if err := e.Start(":8080"); err != nil && err != http.ErrServerClosed {
            log.Fatalf("shutting down the server: %v", err)
        }
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, os.Interrupt)
    <-quit
    log.Println("Shutting down server...")

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    if err := e.Shutdown(ctx); err != nil {
        log.Fatal("Server forced to shutdown:", err)
    }

    log.Println("Server exiting")
}
                

10. Dev/Prod Parity

Mantenha o desenvolvimento, o teste e a produção o mais semelhante possível

  • Os ambientes de desenvolvimento, teste e produção devem ser o mais semelhantes possível para evitar "surpresas" na produção.
  • Reduz riscos e inconsistências entre ambientes.

# Dockerfile para desenvolvimento e produção
FROM golang:1.22

WORKDIR /app
COPY . .

RUN go build -o myapp

CMD ["./myapp"]
                

11. Logs

Trate logs como fluxos de eventos

  • A aplicação deve tratar logs como fluxos de eventos contínuos e não se preocupar com o armazenamento ou a roteamento dos logs.
  • Facilita a centralização e a análise de logs.

package main

import (
    "log"
    "net/http"

    "github.com/labstack/echo/v4"
)

func main() {
    e := echo.New()
    e.GET("/ping", func(c echo.Context) error {
        log.Println("Received request for /ping")
        return c.String(200, "pong")
    })
    log.Fatal(e.Start(":8080"))
}
                

12. Admin Processes

Execute tarefas de administração/gerenciamento como processos únicos

  • Tarefas administrativas (como migrações de banco de dados) devem ser executadas como processos únicos e independentes do ciclo de vida da aplicação.
  • Facilita a execução de tarefas administrativas sem interromper o serviço principal.

package main

import (
    "database/sql"
    "fmt"
    _ "github.com/lib/pq"
)

func main() {
    db, err := sql.Open("postgres", "user=username dbname=mydb sslmode=disable")
    if err != nil {
        panic(err)
    }
    defer db.Close()

    _, err = db.Exec("ALTER TABLE mytable ADD COLUMN newcolumn TEXT")
    if err != nil {
        panic(err)
    }

    fmt.Println("Migration completed successfully")
}
                

Mão no código...

Test-Driven Development (TDD)

Abordagem de desenvolvimento onde os testes são escritos antes do código funcional

  • Reduz bugs e melhora a qualidade do código
  • Facilita a manutenção e refatoração do código

O Ciclo TDD

  • Escreva um teste que falhe
  • Escreva o código mínimo necessário para passar o teste
  • Refatore o código para padrões aceitáveis

Exemplo de TDD em Go

Vamos criar uma função simples para adicionar dois números e escrever testes para ela.


package main

import "fmt"

// Add function
func Add(a, b int) int {
    return a + b
}

func main() {
    fmt.Println(Add(2, 3))
}
                

Escrevendo Testes


package main

import "testing"

// TestAdd function
func TestAdd(t *testing.T) {
    result := Add(2, 3)
    if result != 5 {
        t.Errorf("Add(2, 3) = %d; want 5", result)
    }
}
                

Table-Driven Tests

Usando Table-Driven Tests para melhorar a legibilidade e manutenção dos testes


package main

import "testing"

// TestAdd function with Table-Driven Tests
func TestAdd(t *testing.T) {
    var tests = []struct {
        a, b, expected int
    }{
        {1, 1, 2},
        {2, 3, 5},
        {10, 20, 30},
    }

    for _, tt := range tests {
        testname := fmt.Sprintf("%d+%d", tt.a, tt.b)
        t.Run(testname, func(t *testing.T) {
            result := Add(tt.a, tt.b)
            if result != tt.expected {
                t.Errorf("got %d, want %d", result, tt.expected)
            }
        })
    }
}
                

Benefícios do TDD

  • Maior confiança no código
  • Facilidade na refatoração
  • Documentação viva

Leia mais sobre TDD em Go no meu artigo.

Perguntas?

Obrigado!