golang-dev
Go development best-practices guide. Covers library choices, build/test commands, and escape analysis.
1. CLI Development (cobra)
When to use: Any Go project exposing a command-line interface.
Always use github.com/spf13/cobra. Structure:
cmd/
root.go # Root command + global flags
serve.go # Subcommand: serve
migrate.go # Subcommand: migrate
main.go # Only calls cmd.Execute()
Root command pattern
// cmd/root.go
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var cfgFile string
var rootCmd = &cobra.Command{
Use: "myapp",
Short: "Short description of myapp",
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func init() {
cobra.OnInitialize(InitConfig)
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default $HOME/.myapp.yaml)")
}
func InitConfig() {
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else {
home, _ := os.UserHomeDir()
viper.AddConfigPath(home)
viper.AddConfigPath(".")
viper.SetConfigName(".myapp")
viper.SetConfigType("yaml")
}
viper.AutomaticEnv()
_ = viper.ReadInConfig()
}
Subcommand pattern
// cmd/serve.go
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Start the HTTP server",
RunE: func(cmd *cobra.Command, args []string) error {
port := viper.GetInt("port")
fmt.Printf("Listening on :%d\n", port)
// start server...
return nil
},
}
func init() {
rootCmd.AddCommand(serveCmd)
serveCmd.Flags().IntP("port", "p", 8080, "server port")
viper.BindPFlag("port", serveCmd.Flags().Lookup("port"))
}
Key rules
- Use
RunE(notRun) so errors propagate instead of silently failing. PersistentFlags()for flags inherited by all subcommands;Flags()for command-specific.- Bind flags to viper via
viper.BindPFlag()ininit()to unify flag and config access.
CLI usage metrics (gosdk cobra hook)
If github.com/bizshuk/gosdk is available, record every CLI invocation by wiring the metric hook into the root command before Execute():
import "github.com/bizshuk/gosdk/metric"
func init() {
metric.CobraCMDHook(rootCmd)
}
Each execution emits one metric via Prometheus remote-write to the backend configured by the METRIC_URL viper key (default: VictoriaMetrics http://localhost:8428/api/v1/write; override with viper.Set("METRIC_URL", ...), the APP_METRIC_URL env var when using config.Default(), or METRIC_URL env var with viper.AutomaticEnv()):
command_line_trigger{cmd="myapp sub leaf", flag="env-verbose"} = 1
cmd— full command chain, root → leaf (cmd.CommandPath()).flag— flags the user actually set, collected across the whole chain, alphabetically sorted, joined with-; empty string when no flag was set.- The hook wraps (does not replace) an existing
PersistentPreRunEon root. If any subcommand defines its ownPersistentPreRunE, setcobra.EnableTraverseRunHooks = true(once, ininit()), otherwise cobra skips the root hook and no metric is emitted for that subcommand.
Monitor subcommand
monitorsub command is used to overall monitoring for the command, with--mergeis one time fetch and merge into pre defined files
2. Configuration (viper)
When to use: Any Go project that needs configuration management.
IMPORTANT: If github.com/bizshuk/gosdk is available, always use its config.Default() for configuration loading. Fall back to raw viper manual setup only if the SDK is not supported or available.
Gosdk DB connections (SQLite / MySQL / PostgreSQL)
For database access, use the db package. Each storage type is a service with its own global singleton and a flat <TYPE>_<FIELD> viper key:
import "github.com/bizshuk/gosdk/db"
config.Default()
if viper.IsSet("SQLITE_PATH") {
if err := db.InitSQLite(); err != nil { /* handle */ }
}
if viper.IsSet("MYSQL_DSN") {
if err := db.InitMySQL(); err != nil { /* handle */ }
}
if viper.IsSet("POSTGRES_DSN") {
if err := db.InitPostgres(); err != nil { /* handle */ }
}
// Anywhere later in the process:
gormDB := db.DefaultSQLite.DB()
defer db.DefaultSQLite.Close()
YAML (flat keys, uppercase underscore):
SQLITE_PATH: ./app.db
Env var (with APP_ prefix from config.Default()): APP_SQLITE_PATH, APP_MYSQL_DSN, APP_POSTGRES_DSN.
Why a singleton per storage type: micro-service concept forbids two of the same storage type in one process. Init<Storage>() refuses double-init and returns an error if called twice. The *gorm.DB is cached in the singleton, so any code path that needs it just reads db.DefaultSQLite.DB() instead of opening its own connection.
Always use github.com/spf13/viper. Loading precedence (highest wins):
Environment variables(viper.AutomaticEnv())CLI flags(bound viaviper.BindPFlag())Config file(YAML preferred)Defaults(viper.SetDefault())
Config struct pattern
For non-DB settings, unmarshal nested config (e.g., server.* blocks) into a typed struct. Database access uses the db package above; do not include DB fields here.
type Config struct {
Server ServerConfig `mapstructure:"server"`
}
type ServerConfig struct {
Port int `mapstructure:"port"`
ReadTimeout int `mapstructure:"read_timeout"`
}
func LoadConfig() (*Config, error) {
var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("unmarshal config: %w", err)
}
return &cfg, nil
}
Corresponding YAML
# .myapp.yaml
server:
port: 8080
read_timeout: 30
Common pitfalls
Env prefix:Callviper.SetEnvPrefix("MYAPP")so env vars likeMYAPP_SERVER_PORTmap correctly.Nested keys in env:Useviper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))soserver.portmaps toMYAPP_SERVER_PORT.Type mismatch:viper.Unmarshalusesmapstructuretags, notjsonoryamltags.
3. Common Libraries
| Category | Library | When to use |
|---|---|---|
| Logging | go.uber.org/zap |
Structured logging. zap.NewProduction() for JSON, zap.NewDevelopment() for console. |
| Testing | github.com/stretchr/testify |
assert (continue on fail), require (stop on fail). |
| Linting | golangci-lint |
Meta-linter. Install: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest. |
| HTTP | net/http (stdlib) |
Prefer stdlib. Use github.com/gin-gonic/gin only when routing complexity justifies it. |
| Hot Reload | github.com/air-verse/air |
Dev-time file watcher. Run air instead of go run. |
| Mocking | github.com/bytedance/mockey |
Runtime monkey-patching for tests. |
| CLI | github.com/spf13/cobra |
CLI scaffolding (see Section 1). |
| Config | github.com/spf13/viper |
Config management (see Section 2). |
zap quick setup
func NewLogger(isDev bool) (*zap.Logger, error) {
if isDev {
return zap.NewDevelopment()
}
return zap.NewProduction()
}
testify quick pattern
func TestGetUser(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
user, err := GetUser(ctx, "123")
require.NoError(err) // stops test if err != nil
assert.Equal("Alice", user.Name) // continues even if fails
}
mockey quick pattern
Use mockey to mock functions and methods. Always execute mocking code within mockey.PatchConcurrently to ensure thread safety:
func TestGetUserDiscount(t *testing.T) {
mockey.PatchConcurrently(t, func() {
// Mock a package-level function
mockey.Mock(FetchUserFromDB).Return(&User{ID: "123", Age: 65}, nil).Build()
discount, err := GetUserDiscount("123")
assert.NoError(t, err)
assert.Equal(t, 0.2, discount)
})
}
4. Build Commands
| Task | Command |
|---|---|
| Standard build | go build ./... |
| Production binary | go build -ldflags="-s -w" -o bin/app ./cmd/app |
| Static binary (no CGO) | CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/app ./cmd/app |
| Version injection | go build -ldflags="-X main.version=1.2.3 -X main.buildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" |
| Cross compile (Linux) | GOOS=linux GOARCH=amd64 go build -o bin/app-linux ./cmd/app |
| Race detector | go build -race ./... |
Flag explanation
-sstrips symbol table,-wstrips DWARF debug info — ~30-40% smaller binary.CGO_ENABLED=0produces a fully static binary (no libc dependency) — ideal forscratch/distrolessDocker images.-raceenables the race detector — use in CI but NOT in production (10x slowdown).
5. Test Commands
DO NOT create interface for pure test or coverage
| Task | Command |
|---|---|
| Run all tests | go test ./... |
| Verbose | go test -v ./... |
| Race detector | go test -race ./... |
| Coverage | go test -cover -coverprofile=coverage.out ./... |
| View coverage HTML | go tool cover -html=coverage.out |
| Specific test | go test -run TestFunctionName ./pkg/... |
| Benchmarks | go test -bench=. -benchmem -run=^$ ./... |
| Short mode | go test -short ./... |
| Disable caching | go test -count=1 ./... |
| Timeout | go test -timeout 30s ./... |
| Disable inlining | go test -gcflags="all=-N -l" ./... |
Disabling inlining and optimizations
go test -gcflags="all=-N -l" ./...
-Ndisables all compiler optimizations.-ldisables inlining (function calls remain as actual calls, not inlined).all=applies the flags toall packagesbeing compiled, not just the test package. Withoutall=, only the direct test target gets the flags — dependencies may still be inlined.
Use cases:
- Debugging with
dlv(delve) — requires non-inlined frames for accurate breakpoints and variable inspection. - Mocking and patching — libraries like
mockey(or other monkey-patching libraries) rewrite function machine code at runtime. If a target function is inlined or optimized, the patch will fail because there is no independent function entry point to rewrite. - Accurate escape analysis — inlining can change escape decisions, so disable it to see "true" escape behavior.
- Diagnosing bugs that only manifest with/without compiler optimizations.
6. Escape Analysis
When to use: Investigating heap allocations, optimizing memory-sensitive hot paths.
Commands
# Basic: shows escape decisions
go build -gcflags='-m' ./...
# Detailed: shows reasoning for each decision
go build -gcflags='-m=2' ./...
# Specific package
go build -gcflags='-m=2' ./pkg/handler/...
# Filter for escapes only
go build -gcflags='-m=2' ./... 2>&1 | grep "escapes to heap"
Common escape reasons
| Output | Cause | Fix |
|---|---|---|
leaking param: x |
Param stored beyond function scope | Avoid storing pointer params in long-lived structs |
moved to heap: too large |
Stack frame exceeds ~10MB | Break into smaller allocations or use sync.Pool |
moved to heap: captured by closure |
Variable captured in closure | Pass value explicitly as parameter |
moved to heap: interface conversion |
Concrete assigned to any/interface{} |
Use concrete types in hot paths |
&x escapes to heap |
Returning address of local | Return value instead of pointer when possible |
Verification workflow
- Run
go build -gcflags='-m=2' ./... 2>&1 | grep "escapes to heap"to find escaping allocations. - Focus on
hot paths— handlers, loops, frequently called functions. Don't optimize cold code. - Run
go test -bench=. -benchmemto measure allocs/op before and after changes. - Apply fix, re-run escape analysis to confirm the allocation moved to stack.
- Re-run benchmarks to verify measurable improvement.
Reference: Stack allocation ~0.26ns vs heap ~10.55ns — ~40x penalty per escaped allocation.
7. Quick Reference
| Task | Command |
|---|---|
| Build (dev) | go build ./... |
| Build (prod) | go build -ldflags="-s -w" -o bin/app ./cmd/app |
| Build (static) | CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/app ./cmd/app |
| Test | go test ./... |
| Test (race) | go test -race ./... |
| Test (cover) | go test -cover -coverprofile=coverage.out ./... |
| Test (no inline) | go test -gcflags="all=-N -l" ./... |
| Test (bench) | go test -bench=. -benchmem -run=^$ ./... |
| Escape analysis | go build -gcflags='-m=2' ./... |
| Lint | golangci-lint run ./... |
| Vet | go vet ./... |
| Format | gofmt -w . or goimports -w . |
| Hot reload | air |
| Cross compile | GOOS=linux GOARCH=amd64 go build -o bin/app ./cmd/app |
Source: BizShuk/gosdk — distributed by TomeVault.