Receitas
Banco de dados
Um pool para o processo, consultas que carregam o contexto da requisição, uma transação que desfaz sozinha e migrações aplicadas em ordem.
Trilha não abre o seu banco. O que ele dá são os dois momentos que importam: o Setup, que roda uma vez antes de o servidor subir, e o contexto da requisição, que é o que faz uma consulta parar quando o visitante desiste.
O pool#
database/sql já é um pool. Um por processo — um pool por pacote é um teto de conexões que ninguém somou, e um pool por requisição é uma tempestade de conexões no primeiro minuto movimentado.
// OpenDB opens the pool and proves it works. sql.Open does not connect, so
// a wrong password only shows up on the first query — usually a visitor's.
// The ping moves that failure to the start of the process, where a deploy
// can still be rolled back.
func OpenDB(driver, dsn string) (*sql.DB, error) {
db, err := sql.Open(driver, dsn)
if err != nil {
return nil, err
}
// The database has a connection limit and it is smaller than you think.
// Max open is what one instance may hold; idle equal to it keeps the
// pool from opening and closing a connection per burst.
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(20)
db.SetConnMaxLifetime(30 * time.Minute)
db.SetConnMaxIdleTime(5 * time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("%s: %w", driver, err)
}
return db, nil
}O import que faz de "pgx" um nome de verdade é a única linha que este arquivo não pode ter, porque o repositório não tem dependência externa:
import _ "github.com/jackc/pgx/v5/stdlib" // driver "pgx"
import _ "modernc.org/sqlite" // driver "sqlite", sem cgoPara SQLite há mais uma coisa a dizer, e ela não é opcional: _pragma=journal_mode(WAL) no DSN, mais db.SetMaxOpenConns(1) para escrita. Sem WAL, a segunda escrita concorrente recebe database is locked — e isso vai acontecer em produção, não nos seus testes.
Onde ele é aberto#
// SetupDB is what app/setup.go does with the pool: open it, hand it to the
// packages that query, tell the health probe about it, and close it on the
// way out.
func SetupDB(a *trilha.App) error {
db, err := OpenDB("pgx", os.Getenv("DATABASE_URL"))
if err != nil {
return err
}
DB = db
a.Check("db", func(ctx context.Context) error { return db.PingContext(ctx) })
a.OnShutdown(func(*trilha.App) error { return db.Close() })
return nil
}Três coisas em seis linhas, e as duas últimas são as que se esquecem. a.Check coloca o pool dentro de /_trilha/health/ready, então uma instância que perdeu o banco para de receber tráfego em vez de responder 500 para todo mundo. a.OnShutdown fecha o pool depois da última requisição, não durante ela.
Lendo#
// ArticleBySlug reads one row. sql.ErrNoRows is not a failure of the
// server: it is the page not existing, and a handler that lets it through
// answers 500 to something that deserved a 404.
func ArticleBySlug(ctx context.Context, slug string) (Article, error) {
var a Article
err := DB.QueryRowContext(ctx, `SELECT id, slug, title, published_at FROM articles WHERE slug = $1`, slug).
Scan(&a.ID, &a.Slug, &a.Title, &a.Published)
switch {
case errors.Is(err, sql.ErrNoRows):
return Article{}, trilha.ErrNotFound
case err != nil:
return Article{}, fmt.Errorf("article %q: %w", slug, err)
}
return a, nil
}sql.ErrNoRows é o bug mais comum deste arquivo. Ele não é uma falha do servidor: é a página não existir. Devolver trilha.ErrNotFound transforma isso no 404 que o visitante merece — e, numa rota /api, num corpo problem+json com o status certo.
Uma lista é a mesma coisa, com as linhas fechadas por um defer e o rows.Err() conferido no fim, porque uma conexão que caiu no meio se parece exatamente com o fim da lista:
// Articles reads a list. The context is the request's: when the visitor
// gives up, the query is cancelled instead of holding a connection for a
// page nobody will read.
func Articles(ctx context.Context, limit int) ([]Article, error) {
rows, err := DB.QueryContext(ctx, `SELECT id, slug, title, published_at FROM articles ORDER BY published_at DESC LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Article
for rows.Next() {
var a Article
if err := rows.Scan(&a.ID, &a.Slug, &a.Title, &a.Published); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}Escrevendo, e desfazendo#
// InTx runs fn inside a transaction. The rollback is deferred without a
// condition because rolling back a committed transaction does nothing: that
// is what keeps a panic in the middle from leaving the transaction open.
func InTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if err := fn(tx); err != nil {
return err
}
return tx.Commit()
}O defer tx.Rollback() sem condição é o ponto: desfazer uma transação que já foi confirmada não faz nada, então a chamada adiada é de graça no caminho feliz e é a única coisa que fecha a transação quando o código do meio entra em pânico.
Migrações#
Uma ferramenta de migração é uma escolha boa. Ela também é uma dependência, um binário na imagem e um passo no deploy — e a coisa toda são trinta linhas com embed:
// Migrate applies every file in migrations/ the database has not seen, in
// name order, each one with its record in the same transaction. Either the
// migration and its receipt land together or neither does.
func Migrate(ctx context.Context, db *sql.DB) error {
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL)`); err != nil {
return err
}
names, err := fs.Glob(migrations, "migrations/*.sql")
if err != nil {
return err
}
sort.Strings(names)
for _, name := range names {
var applied int
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM schema_migrations WHERE name = $1`, name).Scan(&applied); err != nil {
return err
}
if applied > 0 {
continue
}
body, err := migrations.ReadFile(name)
if err != nil {
return err
}
err = InTx(ctx, db, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, string(body)); err != nil {
return fmt.Errorf("%s: %w", name, err)
}
_, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (name, applied_at) VALUES ($1, $2)`, name, time.Now().UTC())
return err
})
if err != nil {
return err
}
}
return nil
}O arquivo e o recibo dele caem na mesma transação: ou os dois, ou nenhum. Chame isso do Setup antes de o servidor escutar, ou de um comando separado se o seu deploy aplica as migrações antes de subir a versão nova — que é o formato melhor a partir da segunda instância.
sqlc#
Tudo acima escreve o Scan à mão. O sqlc gera esse código a partir do SQL que você já escreveu: entra um arquivo .sql, sai um método tipado, e uma coluna que muda de nome vira erro de compilação.
version: "2"
sql:
- engine: postgresql
queries: internal/db/query.sql
schema: internal/db/migrations
gen:
go:
package: db
out: internal/dbEle encaixa no framework sem adaptador nenhum, porque o que sai é Go comum: o *db.Queries gerado vai no Setup exatamente onde o DB vai acima. A troca é o gerador no ciclo — mais um comando para rodar quando o SQL muda, e mais uma coisa para explicar a quem chega.