한국어 문서: 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.
- config — define an aggregate that embeds
config.Config, then read and validate the per-phase YAML (pkg/config,pkg/phase). - service — your business logic. A factory (
NewXxxService) receives the infrastructure it needs (*sql.DB,client.Client,*log.Logger) and config injected by type. - api (or consumer) — the HTTP/Kafka surface. Implement just
Route(gin.IRouter)/Consume(consumer.Consumer)and the routes/handlers are wired for you. - run — hand the aggregate to
container.New(cfg), register the factory functions, and callRun(). 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 downEach 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
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)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.
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 / SIGTERMMode 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.
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.DBSo go-lib pulls in no driver, and you compile in only the one you use. For full control there are two overrides:
Config.Connector(adriver.Connector,yaml:"-") overridesDriver/DSN— bring any driver or a custom/wrapped connector. This mirrors howlog.Config.OutputoverridesOutputPath.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) { /* ... */ }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()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 freshT, layering each later document over the earlier ones (e.g.Read[Config](base, secrets)). It takes raw[]byte— not a phase enum or anembed.FS— so the caller decides where the bytes come from and passes them in.Valid(cfg)validates thevalidatestruct tags (go-playground/validator), accepting any struct so a whole aggregate (embedded sub-configs included) validates at once.Readdeliberately skips validation so you can fill runtime-only fields first, then callValid.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 { /* ... */ }container.New(cfg) starts a builder that turns the config bundle plus your
services and apis into a runnable
gontainer. Pass your whole aggregate
config — New 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 / SIGTERMThree roles:
- service — a factory
func NewXxxService(deps...) (*Xxx[, func() error][, error]). gontainer builds it with deps injected; a returned cleanupfunc() erroris called on shutdown. If the built service implementsOpener(Open() error),Openis called once after every service is built. - api — a factory
func NewXxxApi(deps...) *Xxxwhose result implementsApi(Route(gin.IRouter)). Before the server serves, each built api'sRouteis called with the engine, so the api mounts its own routes — no separate router wiring. - consumer — a factory
func NewXxxConsumer(deps...) *Xxxwhose result implementsConsumer(Consume(consumer.Consumer)). When the Kafka consumer is enabled, each built consumer'sConsumeis called with it — binding topic handlers — before it opens. With a consumer but no server,Runblocks 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.
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.