Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

go-lib

한국어 문서: docs/README.ko.md

go-lib is a Go framework for building a single product. It pre-wires the standard infrastructure — logger, HTTP client/server, database, Kafka — behind a config bundle and a container, so an application developer gets a working service simply by writing an api and a service.

The flow comes down to four steps.

  1. config — define an aggregate that embeds config.Config, then read and validate the per-phase YAML (pkg/config, pkg/phase).
  2. service — your business logic. A factory (NewXxxService) receives the infrastructure it needs (*sql.DB, client.Client, *log.Logger) and config injected by type.
  3. api (or consumer) — the HTTP/Kafka surface. Implement just Route(gin.IRouter) / Consume(consumer.Consumer) and the routes/handlers are wired for you.
  4. run — hand the aggregate to container.New(cfg), register the factory functions, and call Run(). The container wires only the enabled infrastructure, opens each service, mounts the apis on the server, and keeps the service up until a signal arrives.
import _ "modernc.org/sqlite" // the application picks the driver and blank-imports it

cfg, err := appconfig.Load()
if err != nil { panic(err) }

err = container.New(cfg).
    Service(todo.NewService). // NewService(*sql.DB) *Service
    Api(todo.NewApi).         // NewApi(*Service) *Api — routes via Route
    Run()                     // blocks until Ctrl+C / SIGTERM, then tears down

Each infrastructure's enabled flag defaults to false, so only what you turn on in config gets wired (e.g. enable server/db/log and omit Kafka). The working reference is the todo demo under cmd/ + internal/ — just copy its shape. The per-package details follow below.

pkg/log              # zerolog-based logger
pkg/http/client      # imroc/req-based global HTTP client
pkg/http/server      # gin-based HTTP server
pkg/db               # driver-agnostic database/sql pool + tx helpers
pkg/kafka/producer   # sarama-based Kafka producer
pkg/kafka/consumer   # sarama-based Kafka consumer group
pkg/config           # read/validate/render a config + embeddable service bundle
pkg/container        # wire a config.Config bundle into gontainer
pkg/phase            # local|dev|live deployment phase enum

pkg/log

A small structured logger built on zerolog. zerolog was chosen because it was the fastest backend in the internal/logbench benchmark, then reimplemented independently.

backend ns/op B/op allocs/op
zerolog 155 128 1
zap 376 640 2
slog 486 136 2
logrus 1284 2388 35

Package-level helpers log through a default logger; Open (or NewContainer) swaps that default for a configured one.

log.Open(log.Config{Level: log.DebugLevel, Format: log.ConsoleFormat})
log.Info("service started", "port", 8080)

pkg/http/client

An imroc/req-based global HTTP client. After a single Open (or NewContainer), issue requests anywhere via the package-level NewRequest().

// once at startup
client.Open(client.Config{
    BaseURL:         "https://api.example.com",
    Retry:           3,             // capped exponential backoff (with jitter) between retries
    RetryBackoffMin: 100 * time.Millisecond,
    RetryBackoffMax: 2 * time.Second,
    Log:             true,          // request/response logging through pkg/log
})

// observe failures (transport errors and 5xx/429 after retries)
client.OnFailure(func(resp *req.Response, err error) {
    // record metrics, alert, etc.
})

// anywhere afterwards
resp, err := client.NewRequest().SetSuccessResult(&out).Get("/health")

Retries use req's capped exponential backoff with jitter and fire on transport errors and server-side error statuses (5xx, 429). Failure handlers registered via OnFailure (or Config.FailureHandlers) run once per failed request, after retries are exhausted. When Log is set, each request and response is logged through pkg/log (error level for error statuses); req's own transport-level logs are routed through pkg/log too, so nothing bypasses the logger.

pkg/http/server

A gin-based HTTP server. New(cfg, middlewares...) builds it with your middlewares after Recovery and the optional request logger; Run serves until an interrupt/terminate signal, then shuts down gracefully — so it works as a long-lived entrypoint. Register routes on Engine(); a GET /health liveness endpoint is built in. NewContainer still offers the background-goroutine factory form.

srv := server.New(server.Config{
    Addr: ":8080",
    Mode: "release",     // gin mode: debug | release | test
    Log:  true,          // request logging through pkg/log
}, myMiddleware)
srv.Engine().GET("/ping", func(c *gin.Context) { c.String(200, "pong") })
if err := srv.Run(); err != nil { /* ... */ } // blocks until Ctrl+C / SIGTERM

Mode defaults to release, so a zero Config yields a quiet server on :8080. All logging goes through pkg/log: request lines (when Log is set, error level for 5xx) and gin's own output are both routed there. The request logger checks the level first and skips building a line the logger would drop.

pkg/db

A thin database/sql wrapper that stays driver-agnostic: pkg/db never imports a database driver. Your program blank-imports the driver it wants — that runs the driver's init(), which registers it — and you name it in Config.Driver:

import _ "github.com/jackc/pgx/v5/stdlib" // in YOUR main, not the library

cfg := db.Config{Driver: "pgx", DSN: "postgres://user:pass@host:5432/app"}
conn, err := db.New(cfg) // *sql.DB

So go-lib pulls in no driver, and you compile in only the one you use. For full control there are two overrides:

  • Config.Connector (a driver.Connector, yaml:"-") overrides Driver/DSN — bring any driver or a custom/wrapped connector. This mirrors how log.Config.Output overrides OutputPath.
  • db.NewWith(conn *sql.DB) installs a pool you built yourself (custom driver, testcontainers, a mock).

The pool is exposed both as a constructible *sql.DB and a process-global default behind db.DB(), with transaction helpers:

// Managed transaction: commits on nil, rolls back on error or panic.
err := db.Tx(ctx, conn, nil, func(tx *sql.Tx) error {
    _, err := tx.ExecContext(ctx, "insert into ...", args...)
    return err
})

// Or begin manually on the global pool.
tx, err := db.WriteTx(ctx) // db.ReadTx(ctx) for read-only

// TX is the query surface shared by *sql.DB and *sql.Tx, so a repository method
// can accept either a pool or an open transaction.
func (r *Repo) Find(ctx context.Context, q db.TX, id int) (Row, error) { /* ... */ }

pkg/kafka

sarama-based Kafka, split into a producer and a consumer group. Each is a self-contained infra package with its own Config (gated by Enabled, default false) and a NewContainer factory in gontainer's shape, so pkg/container wires them from the config bundle. sarama's own logging is routed through pkg/log.

producer publishes synchronously, exposed both as a constructible Producer and a process-global default behind producer.Send:

if err := producer.InitProducer(producer.Config{
    Brokers: []string{"localhost:9092"},
}); err != nil { /* ... */ }
partition, offset, err := producer.Send("topic", key, value)

consumer is a consumer group: register one Handler per topic with Consume, then Open runs them in the background until Close. A handler that returns nil marks its message consumed (committed); a non-nil error leaves it uncommitted for redelivery and is logged. Handlers must all be registered before Open — with the container you don't call these directly (see below).

c, err := consumer.NewConsumer(consumer.Config{
    Brokers: []string{"localhost:9092"},
    GroupID: "orders",
    Offset:  "oldest", // or "newest" (default)
})
if err != nil { /* ... */ }
c.Consume("orders.created", func(m *consumer.Message) error {
    var order Order
    return m.Bind(&order) // nil → commit, error → redeliver
})
if err := c.Open(); err != nil { /* ... */ }
defer c.Close()

pkg/config

Every service repeats the same config-struct boilerplate; pkg/config gathers it up so per-project config packages stay thin.

Three generic helpers cover reading and checking any config:

  • Read[T](docs ...[]byte) (T, error) unmarshals YAML into a fresh T, layering each later document over the earlier ones (e.g. Read[Config](base, secrets)). It takes raw []bytenot a phase enum or an embed.FS — so the caller decides where the bytes come from and passes them in.
  • Valid(cfg) validates the validate struct tags (go-playground/validator), accepting any struct so a whole aggregate (embedded sub-configs included) validates at once. Read deliberately skips validation so you can fill runtime-only fields first, then call Valid.
  • String(cfg) renders the config as compact JSON for a startup log line.

config.Config is an embeddable bundle of the standard sub-configs — the logger, HTTP client, HTTP server, database, and Kafka producer/consumer. Embed it in your aggregate to carry them all; Valid() and String() are also available as methods on it (receiver forms of the package-level functions). pkg/container turns the bundle into gontainer options.

type Config struct {
    config.Config `yaml:",inline"` // Log, Client, Server, DB, Producer, Consumer
    Billing       Billing `yaml:"billing"` // a feature's own config (optional)
}

cfg, err := config.Read[Config](data) // data: embedded/read []byte
if err != nil { /* ... */ }
if err := config.Valid(cfg); err != nil { /* ... */ }

pkg/container

container.New(cfg) starts a builder that turns the config bundle plus your services and apis into a runnable gontainer. Pass your whole aggregate configNew finds the embedded config.Config by reflection, so you don't extract it yourself. Each enabled sub-config (logger, HTTP client, HTTP server, database, Kafka producer/consumer) is wired automatically — the Enabled flag defaults to false, so a service is wired only when its config sets enabled: true. You register factory functions (NewXxxService / NewXxxApi / NewXxxConsumer) and gontainer injects their dependencies — including the config fields New registers from the aggregate:

err := container.New(cfg). // cfg embeds config.Config; New picks it up
    Use(myMiddleware).          // gin middleware for the server
    Service(todo.NewService).   // NewService(*sql.DB) *Service
    Api(todo.NewApi).           // NewApi(*Service) *Api
    Consumer(orders.NewConsumer). // optional: NewConsumer(deps...) *Consumer
    Run()                       // blocks: serves until Ctrl+C / SIGTERM

Three roles:

  • service — a factory func NewXxxService(deps...) (*Xxx[, func() error][, error]). gontainer builds it with deps injected; a returned cleanup func() error is called on shutdown. If the built service implements Opener (Open() error), Open is called once after every service is built.
  • api — a factory func NewXxxApi(deps...) *Xxx whose result implements Api (Route(gin.IRouter)). Before the server serves, each built api's Route is called with the engine, so the api mounts its own routes — no separate router wiring.
  • consumer — a factory func NewXxxConsumer(deps...) *Xxx whose result implements Consumer (Consume(consumer.Consumer)). When the Kafka consumer is enabled, each built consumer's Consume is called with it — binding topic handlers — before it opens. With a consumer but no server, Run blocks on the signal itself so the process stays up.

New registers every field of the aggregate config as a service (flattening the embedded bundle), so factories inject feature configs, log.Config, etc. by type. Use adds gin middlewares; Option(...) is an escape hatch for raw gontainer options; Build() returns the options to run gontainer yourself. When the server is enabled, Run serves until a signal, then shuts down gracefully.

pkg/phase

The deployment phase a service runs in — Local, Dev, or Live — as a typed enum instead of bare environment strings. Its zero value is Local, so an unset environment is the safest phase. String() returns the canonical name, which is also the per-phase config file stem (local.yml, dev.yml, live.yml).

p, err := phase.FromEnv()          // reads APP_ENV; unset → Local, typo → error
if err != nil { /* ... */ }
docs, err := appconfig.Bytes(p)    // p.String()+".yml"

New parses a name, accepting aliases (development→dev, prod/production→live) and rejecting anything unrecognized so a misspelled phase fails fast at startup.

About

common go library

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages